Merge branch 'stable-3.7'

* stable-3.7:
  Enable CheckReturnValue
  Don't show the Owners Column if the plugin is disabled.

Change-Id: I5f84619f459f6d456521a5267a1a1c7cc9e8bf0f
diff --git a/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersIT.java b/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersIT.java
index 8ea5a5b..f7a2943 100644
--- a/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersIT.java
+++ b/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersIT.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.plugins.codeowners.api.impl.ProjectCodeOwnersFactory;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
 import com.google.gerrit.plugins.codeowners.backend.config.BackendConfig;
-import com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig;
 import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
 import com.google.gerrit.testing.ConfigSuite;
 import java.util.Arrays;
@@ -45,26 +44,6 @@
  */
 public class AbstractCodeOwnersIT extends AbstractCodeOwnersTest {
   /**
-   * Returns a {@code gerrit.config} without code owner backend configuration to test the default
-   * setup.
-   */
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    Config cfg = new Config();
-
-    // Disable asynchronous posting of change messages during tests to avoid parallel updates to
-    // NoteDb and hence risking LOCK_FAILURES (especially needed since the test API does not retry
-    // on LOCK_FAILURES).
-    cfg.setBoolean(
-        "plugin",
-        "code-owners",
-        GeneralConfig.KEY_ENABLE_ASYNC_MESSAGE_ON_ADD_REVIEWER,
-        /* value= */ false);
-
-    return cfg;
-  }
-
-  /**
    * Returns a {@code gerrit.config} for every code owner backend so that all code owner backends
    * are tested.
    */
diff --git a/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java b/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java
index 32c82a1..1283932 100644
--- a/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java
+++ b/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java
@@ -38,12 +38,17 @@
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.TestCodeOwnerConfigCreation.Builder;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportMode;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigReference;
 import com.google.gerrit.plugins.codeowners.backend.config.BackendConfig;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig;
 import com.google.gerrit.plugins.codeowners.backend.config.StatusConfig;
 import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
 import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
+import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.nio.file.Path;
 import java.util.Map;
@@ -69,6 +74,31 @@
     name = "code-owners",
     sysModule = "com.google.gerrit.plugins.codeowners.acceptance.TestModule")
 public class AbstractCodeOwnersTest extends LightweightPluginDaemonTest {
+  /**
+   * Returns a {@code gerrit.config} without code owner backend configuration to test the default
+   * setup.
+   */
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+
+    // Disable asynchronous posting of change messages during tests to avoid parallel updates to
+    // NoteDb and hence risking LOCK_FAILURES (especially needed since the test API does not retry
+    // on LOCK_FAILURES).
+    cfg.setBoolean(
+        "plugin",
+        "code-owners",
+        GeneralConfig.KEY_ENABLE_ASYNC_MESSAGE_ON_ADD_REVIEWER,
+        /* value= */ false);
+    cfg.setBoolean(
+        "plugin",
+        "code-owners",
+        GeneralConfig.KEY_ENABLE_ASYNC_MESSAGE_ON_CODE_OWNER_APPROVAL,
+        /* value= */ false);
+
+    return cfg;
+  }
+
   @Inject private ProjectOperations projectOperations;
 
   private CodeOwnerConfigOperations codeOwnerConfigOperations;
@@ -341,4 +371,13 @@
     }
     throw new IllegalStateException("unknown code owner backend: " + backend.getClass().getName());
   }
+
+  protected CodeOwnerConfigReference createCodeOwnerConfigReference(
+      CodeOwnerConfigImportMode importMode, CodeOwnerConfig.Key codeOwnerConfigKey) {
+    return CodeOwnerConfigReference.builder(
+            importMode, codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath())
+        .setProject(codeOwnerConfigKey.project())
+        .setBranch(codeOwnerConfigKey.branchNameKey().branch())
+        .build();
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigFileInfo.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigFileInfo.java
new file mode 100644
index 0000000..41fa62b
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigFileInfo.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.api;
+
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportMode;
+import java.util.List;
+
+/**
+ * Representation of a code owner config file in the REST API.
+ *
+ * <p>This class determines the JSON format of code owner config files in the REST API.
+ *
+ * <p>The JSON only contains the location of the code owner config file (project, branch, path) and
+ * information about the imported code owner config files. It's not a representation of the code
+ * owner config file content (the content of a code owner config file is represented by {@link
+ * CodeOwnerConfigInfo}).
+ */
+public class CodeOwnerConfigFileInfo {
+  /**
+   * The name of the project from which the code owner config was loaded, or for unresolved imports,
+   * from which the code owner config was supposed to be loaded.
+   */
+  public String project;
+
+  /**
+   * The name of the branch from which the code owner config was loaded, or for unresolved imports,
+   * from which the code owner config was supposed to be loaded.
+   */
+  public String branch;
+
+  /** The path of the code owner config file. */
+  public String path;
+
+  /** Imported code owner config files. */
+  public List<CodeOwnerConfigFileInfo> imports;
+
+  /** Imported code owner config files that couldn't be resolved. */
+  public List<CodeOwnerConfigFileInfo> unresolvedImports;
+
+  /**
+   * Message explaining why this code owner config couldn't be resolved.
+   *
+   * <p>Only set for unresolved imports.
+   */
+  public String unresolvedErrorMessage;
+
+  /**
+   * The import mode.
+   *
+   * <p>Only set for imports.
+   */
+  public CodeOwnerConfigImportMode importMode;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInfo.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInfo.java
index d699029..763dff2 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInfo.java
@@ -34,6 +34,9 @@
    */
   public Boolean ownedByAllUsers;
 
+  /** The code owner config files that have been inspected to gather the code owners. */
+  public List<CodeOwnerConfigFileInfo> codeOwnerConfigs;
+
   /**
    * Debug logs that may help to understand why a user is or isn't suggested as a code owner.
    *
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
index a0df99d..5dc8c9c 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.plugins.codeowners.backend;
 
 import static com.google.gerrit.plugins.codeowners.backend.CodeOwnersInternalServerErrorException.newInternalServerError;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.PLUGIN;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.VERSIONED_META_DATA_CHANGE;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -29,7 +31,9 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.Optional;
@@ -52,6 +56,7 @@
 
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
   private final GitRepositoryManager repoManager;
+  private final ProjectCache projectCache;
   private final PersonIdent serverIdent;
   private final MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory;
   private final RetryHelper retryHelper;
@@ -62,6 +67,7 @@
   protected AbstractFileBasedCodeOwnerBackend(
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       GitRepositoryManager repoManager,
+      ProjectCache projectCache,
       @GerritPersonIdent PersonIdent serverIdent,
       MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
       RetryHelper retryHelper,
@@ -70,6 +76,7 @@
       CodeOwnerConfigParser codeOwnerConfigParser) {
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.repoManager = repoManager;
+    this.projectCache = projectCache;
     this.serverIdent = serverIdent;
     this.metaDataUpdateInternalFactory = metaDataUpdateInternalFactory;
     this.retryHelper = retryHelper;
@@ -129,7 +136,7 @@
 
   @Override
   public Path getFilePath(CodeOwnerConfig.Key codeOwnerConfigKey) {
-    return codeOwnerConfigKey.filePath(defaultFileName);
+    return codeOwnerConfigKey.filePath(getFileName(codeOwnerConfigKey.project()));
   }
 
   @Override
@@ -188,9 +195,13 @@
 
   private String getFileName(Project.NameKey project) {
     return defaultFileName
-        + codeOwnersPluginConfiguration
-            .getProjectConfig(project)
-            .getFileExtension()
+        + projectCache
+            .get(project)
+            .flatMap(
+                projectState ->
+                    codeOwnersPluginConfiguration
+                        .getProjectConfig(projectState.getNameKey())
+                        .getFileExtension())
             .map(ext -> "." + ext)
             .orElse("");
   }
@@ -251,8 +262,10 @@
                   codeOwnerConfigKey)
               .setCodeOwnerConfigUpdate(codeOwnerConfigUpdate);
 
-      try (MetaDataUpdate metaDataUpdate =
-          createMetaDataUpdate(codeOwnerConfigKey.project(), repository, currentUser)) {
+      try (RefUpdateContext pluginCtx = RefUpdateContext.open(PLUGIN);
+          RefUpdateContext ctx = RefUpdateContext.open(VERSIONED_META_DATA_CHANGE);
+          MetaDataUpdate metaDataUpdate =
+              createMetaDataUpdate(codeOwnerConfigKey.project(), repository, currentUser)) {
         codeOwnerConfigFile.commit(metaDataUpdate);
       }
 
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
index cba9409..55421e9 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.events.ReviewerAddedListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -61,6 +62,7 @@
 
     DynamicSet.bind(binder(), ExceptionHook.class).to(CodeOwnersExceptionHook.class);
     DynamicSet.bind(binder(), OnPostReview.class).to(OnCodeOwnerApproval.class);
+    DynamicSet.bind(binder(), CommentAddedListener.class).to(OnCodeOwnerApproval.class);
     DynamicSet.bind(binder(), OnPostReview.class).to(OnCodeOwnerOverride.class);
     DynamicSet.bind(binder(), ReviewerAddedListener.class).to(CodeOwnersOnAddReviewer.class);
   }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFile.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFile.java
index 90fd878..0669b68 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFile.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFile.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.plugins.codeowners.backend;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.PLUGIN;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -25,6 +26,7 @@
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Optional;
@@ -260,8 +262,11 @@
         update.getRepository().exactRef(getRefName()) != null,
         "branch %s does not exist",
         getRefName());
-
-    return super.commit(update);
+    // The commit goes to an ordinary branch (e.g. refs/heads/main). PLUGIN context is enough for
+    // such cases.
+    try(RefUpdateContext ctx = RefUpdateContext.open(PLUGIN)) {
+      return super.commit(update);
+    }
   }
 
   @Override
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScanner.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScanner.java
index d0bd003..0fa31e1 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScanner.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScanner.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.plugins.codeowners.backend;
 
 import static com.google.gerrit.plugins.codeowners.backend.CodeOwnersInternalServerErrorException.newInternalServerError;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.PLUGIN;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 
@@ -24,7 +25,9 @@
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -57,6 +60,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated gitRefUpdated;
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
   private final Provider<PersonIdent> serverIdentProvider;
   private final Provider<IdentifiedUser> identifiedUser;
@@ -64,10 +68,12 @@
   @Inject
   CodeOwnerConfigFileUpdateScanner(
       GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       @GerritPersonIdent Provider<PersonIdent> serverIdentProvider,
       Provider<IdentifiedUser> identifiedUser) {
     this.repoManager = repoManager;
+    this.gitRefUpdated = gitRefUpdated;
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.serverIdentProvider = serverIdentProvider;
     this.identifiedUser = identifiedUser;
@@ -101,7 +107,8 @@
         "updating code owner files in branch %s of project %s",
         branchNameKey.branch(), branchNameKey.project());
 
-    try (Repository repository = repoManager.openRepository(branchNameKey.project());
+    try (RefUpdateContext ctx = RefUpdateContext.open(PLUGIN);
+        Repository repository = repoManager.openRepository(branchNameKey.project());
         RevWalk rw = new RevWalk(repository);
         ObjectInserter oi = repository.newObjectInserter();
         CodeOwnerConfigTreeWalk treeWalk =
@@ -113,7 +120,7 @@
                 /** pathGlob */
                 null)) {
       RevCommit revision = treeWalk.getRevision();
-      DirCache newTree = DirCache.newInCore();
+      DirCache newTree = DirCache.read(rw.getObjectReader(), revision.getTree());
       DirCacheEditor editor = newTree.editor();
 
       boolean dirty = false;
@@ -138,7 +145,7 @@
       editor.finish();
       ObjectId treeId = newTree.writeTree(oi);
       ObjectId commitId = createCommit(oi, commitMessage, revision, treeId);
-      updateBranch(branchNameKey.branch(), repository, revision, commitId);
+      updateBranch(repository, branchNameKey, revision, commitId);
       return Optional.of(rw.parseCommit(commitId));
     } catch (IOException e) {
       throw newInternalServerError(
@@ -194,18 +201,22 @@
   /**
    * Update the given branch.
    *
-   * @param branchName the name of the branch that should be updated
    * @param repository the repository in which the branch should be updated
+   * @param branchNameKey the project and branch that should be updated
    * @param oldObjectId the expected old object ID of the branch
    * @param newObjectId the new object ID that should be set for the branch
    */
   private void updateBranch(
-      String branchName, Repository repository, ObjectId oldObjectId, ObjectId newObjectId)
+      Repository repository,
+      BranchNameKey branchNameKey,
+      ObjectId oldObjectId,
+      ObjectId newObjectId)
       throws IOException {
-    RefUpdate ru = repository.updateRef(branchName);
+    RefUpdate ru = repository.updateRef(branchNameKey.branch());
     ru.setExpectedOldObjectId(oldObjectId);
     ru.setNewObjectId(newObjectId);
     ru.update();
     RefUpdateUtil.checkResult(ru);
+    gitRefUpdated.fire(branchNameKey.project(), ru, identifiedUser.get().state());
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigImport.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigImport.java
new file mode 100644
index 0000000..a75c15b
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigImport.java
@@ -0,0 +1,86 @@
+// 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 com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.MoreObjects;
+import java.util.Optional;
+
+/**
+ * Information about an import of a {@link CodeOwnerConfig}.
+ *
+ * <p>Contains the keys of the importing and the imported code owner config, as well as the
+ * reference that the importing code owner config uses to reference the imported code owner config
+ * (contains the import mode).
+ *
+ * <p>It's possible that this class represents non-resolveable imports (e.g. an import of a
+ * non-existing code owner config). In this case an error message is contained that explains why the
+ * import couldn't be resolved.
+ */
+@AutoValue
+public abstract class CodeOwnerConfigImport {
+  /** Key of the importing code owner config. */
+  public abstract CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig();
+
+  /** Key of the imported code owner config. */
+  public abstract CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig();
+
+  /** The code owner config reference that references the imported code owner config. */
+  public abstract CodeOwnerConfigReference codeOwnerConfigReference();
+
+  /**
+   * If the import couldn't be resolved, a message explaining why the code owner config reference
+   * couldn't be resolved.
+   */
+  public abstract Optional<String> errorMessage();
+
+  @Override
+  public final String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("keyOfImportingCodeOwnerConfig", keyOfImportingCodeOwnerConfig())
+        .add("keyOfImportedCodeOwnerConfig", keyOfImportedCodeOwnerConfig())
+        .add("codeOwnerConfigReference", codeOwnerConfigReference())
+        .add("errorMessage", errorMessage())
+        .toString();
+  }
+
+  /** Creates a {@link CodeOwnerConfigImport} instance for an unresolved import. */
+  @VisibleForTesting
+  public static CodeOwnerConfigImport createUnresolvedImport(
+      CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig,
+      CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig,
+      CodeOwnerConfigReference codeOwnerConfigReference,
+      String errorMessage) {
+    return new AutoValue_CodeOwnerConfigImport(
+        keyOfImportingCodeOwnerConfig,
+        keyOfImportedCodeOwnerConfig,
+        codeOwnerConfigReference,
+        Optional.of(errorMessage));
+  }
+
+  /** Creates a {@link CodeOwnerConfigImport} instance for a resolved import. */
+  @VisibleForTesting
+  public static CodeOwnerConfigImport createResolvedImport(
+      CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig,
+      CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig,
+      CodeOwnerConfigReference codeOwnerConfigReference) {
+    return new AutoValue_CodeOwnerConfigImport(
+        keyOfImportingCodeOwnerConfig,
+        keyOfImportedCodeOwnerConfig,
+        codeOwnerConfigReference,
+        Optional.empty());
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
index 07894ce..7000959 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
@@ -39,7 +39,7 @@
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -50,7 +50,6 @@
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashSet;
-import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
@@ -106,7 +105,7 @@
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> currentUser;
-  private final ExternalIds externalIds;
+  private final ExternalIdCache externalIdCache;
   private final AccountCache accountCache;
   private final AccountControl.Factory accountControlFactory;
   private final PathCodeOwners.Factory pathCodeOwnersFactory;
@@ -127,7 +126,7 @@
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       PermissionBackend permissionBackend,
       Provider<CurrentUser> currentUser,
-      ExternalIds externalIds,
+      ExternalIdCache externalIdCache,
       AccountCache accountCache,
       AccountControl.Factory accountControlFactory,
       PathCodeOwners.Factory pathCodeOwnersFactory,
@@ -137,7 +136,7 @@
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.permissionBackend = permissionBackend;
     this.currentUser = currentUser;
-    this.externalIds = externalIds;
+    this.externalIdCache = externalIdCache;
     this.accountCache = accountCache;
     this.accountControlFactory = accountControlFactory;
     this.pathCodeOwnersFactory = pathCodeOwnersFactory;
@@ -234,6 +233,7 @@
       return resolve(
           pathCodeOwnersResult.get().getPathCodeOwners(),
           pathCodeOwnersResult.get().getAnnotations(),
+          pathCodeOwnersResult.get().resolvedImports(),
           pathCodeOwnersResult.get().unresolvedImports(),
           pathCodeOwnersResult.messages());
     }
@@ -260,6 +260,7 @@
     return resolve(
         codeOwnerReferences,
         /* annotationsByCodeOwnerReference= */ ImmutableMultimap.of(),
+        /* resolvedImports= */ ImmutableList.of(),
         /* unresolvedImports= */ ImmutableList.of(),
         /* pathCodeOwnersMessages= */ ImmutableList.of());
   }
@@ -279,9 +280,11 @@
   private CodeOwnerResolverResult resolve(
       Set<CodeOwnerReference> codeOwnerReferences,
       ImmutableMultimap<CodeOwnerReference, CodeOwnerAnnotation> annotationsByCodeOwnerReference,
-      List<UnresolvedImport> unresolvedImports,
+      ImmutableList<CodeOwnerConfigImport> resolvedImports,
+      ImmutableList<CodeOwnerConfigImport> unresolvedImports,
       ImmutableList<String> pathCodeOwnersMessages) {
     requireNonNull(codeOwnerReferences, "codeOwnerReferences");
+    requireNonNull(resolvedImports, "resolvedImports");
     requireNonNull(unresolvedImports, "unresolvedImports");
     requireNonNull(pathCodeOwnersMessages, "pathCodeOwnersMessages");
 
@@ -313,7 +316,8 @@
               annotationsByCodeOwner.build(),
               ownedByAllUsers.get(),
               hasUnresolvedCodeOwners.get(),
-              !unresolvedImports.isEmpty(),
+              resolvedImports,
+              unresolvedImports,
               messageBuilder.build());
       logger.atFine().log("resolve result = %s", codeOwnerResolverResult);
       return codeOwnerResolverResult;
@@ -573,7 +577,7 @@
    * Looks up the external IDs for the given emails.
    *
    * <p>Looks up all emails from the external ID cache at once, which is more efficient than looking
-   * up external IDs for emails one by one (see {@link ExternalIds#byEmails(String...)}).
+   * up external IDs for emails one by one (see {@link ExternalIdCache#byEmail(String)}).
    *
    * @param messages builder to which debug messages are added
    * @param emails the emails for which the external IDs should be looked up
@@ -583,7 +587,7 @@
       ImmutableList.Builder<String> messages, ImmutableSet<String> emails) {
     try {
       ImmutableMap<String, Collection<ExternalId>> extIdsByEmail =
-          externalIds.byEmails(emails.toArray(new String[0])).asMap();
+          externalIdCache.byEmails(emails.toArray(new String[0])).asMap();
       emails.stream()
           .filter(email -> !extIdsByEmail.containsKey(email))
           .forEach(
@@ -837,12 +841,12 @@
       // emails
       try {
         if (user != null) {
-          if (!permissionBackend.user(user).test(GlobalPermission.MODIFY_ACCOUNT)) {
+          if (!permissionBackend.user(user).test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
             transientCodeOwnerCache.cacheNonResolvable(email);
             messages.add(
                 String.format(
-                    "cannot resolve code owner email %s: account %s is referenced by secondary email"
-                        + " but user %s cannot see secondary emails",
+                    "cannot resolve code owner email %s: account %s is referenced by secondary"
+                        + " email but user %s cannot see secondary emails",
                     email, accountState.account().id(), user.getLoggableName()));
             return false;
           }
@@ -852,7 +856,7 @@
                       + " and user %s can see secondary emails",
                   email, accountState.account().id(), user.getLoggableName()));
           return true;
-        } else if (!permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
+        } else if (!permissionBackend.currentUser().test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
           transientCodeOwnerCache.cacheNonResolvable(email);
           messages.add(
               String.format(
@@ -871,7 +875,7 @@
       } catch (PermissionBackendException ex) {
         throw newInternalServerError(
             String.format(
-                "failed to test the %s global capability", GlobalPermission.MODIFY_ACCOUNT),
+                "failed to test the %s global capability", GlobalPermission.VIEW_SECONDARY_EMAILS),
             ex);
       }
     };
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java
index 7dd9e96..4db3a4c 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java
@@ -52,8 +52,16 @@
   /** Whether there are code owner references which couldn't be resolved. */
   public abstract boolean hasUnresolvedCodeOwners();
 
+  /** Imports which were successfully resolved. */
+  public abstract ImmutableList<CodeOwnerConfigImport> resolvedImports();
+
+  /** Imports which couldn't be resolved. */
+  public abstract ImmutableList<CodeOwnerConfigImport> unresolvedImports();
+
   /** Whether there are imports which couldn't be resolved. */
-  public abstract boolean hasUnresolvedImports();
+  public boolean hasUnresolvedImports() {
+    return !unresolvedImports().isEmpty();
+  }
 
   /** Gets messages that were collected while resolving the code owners. */
   public abstract ImmutableList<String> messages();
@@ -76,7 +84,8 @@
         .add("annotations", annotations())
         .add("ownedByAllUsers", ownedByAllUsers())
         .add("hasUnresolvedCodeOwners", hasUnresolvedCodeOwners())
-        .add("hasUnresolvedImports", hasUnresolvedImports())
+        .add("resolvedImports", resolvedImports())
+        .add("unresolvedImports", unresolvedImports())
         .add("messages", messages())
         .toString();
   }
@@ -87,25 +96,16 @@
       ImmutableMultimap<CodeOwner, CodeOwnerAnnotation> annotations,
       boolean ownedByAllUsers,
       boolean hasUnresolvedCodeOwners,
-      boolean hasUnresolvedImports,
+      ImmutableList<CodeOwnerConfigImport> resolvedImports,
+      ImmutableList<CodeOwnerConfigImport> unresolvedImports,
       List<String> messages) {
     return new AutoValue_CodeOwnerResolverResult(
         codeOwners,
         annotations,
         ownedByAllUsers,
         hasUnresolvedCodeOwners,
-        hasUnresolvedImports,
+        resolvedImports,
+        unresolvedImports,
         ImmutableList.copyOf(messages));
   }
-
-  /** Creates a empty {@link CodeOwnerResolverResult} instance. */
-  public static CodeOwnerResolverResult createEmpty() {
-    return new AutoValue_CodeOwnerResolverResult(
-        /* codeOwners= */ ImmutableSet.of(),
-        /* annotations= */ ImmutableMultimap.of(),
-        /* ownedByAllUsers= */ false,
-        /* hasUnresolvedCodeOwners= */ false,
-        /* hasUnresolvedImports= */ false,
-        /* messages= */ ImmutableList.of());
-  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersChangeMessageUtil.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersChangeMessageUtil.java
new file mode 100644
index 0000000..ee0e9ec
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersChangeMessageUtil.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
+import java.nio.file.Path;
+import java.util.stream.Stream;
+
+/** Utility functions to write code owners {@link ChangeMessage}s. */
+public class CodeOwnersChangeMessageUtil {
+  /**
+   * Appends the given paths to the given message builder.
+   *
+   * @param message message builder to which the paths should be appended
+   * @param pathsToAppend the paths to append to the message builder
+   */
+  public static void appendPaths(StringBuilder message, Stream<Path> pathsToAppend) {
+    pathsToAppend.forEach(
+        path -> message.append(
+          String.format("* `%s`\n", JgitPath.of(path).get().replace("`", "\\`"))));
+  }
+
+  /**
+   * Private constructor to prevent instantiation of this class.
+   *
+   * <p>The class only contains static methods, hence the class never needs to be instantiated.
+   */
+  private CodeOwnersChangeMessageUtil() {}
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java
index 5052edb..3ce8e01 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.plugins.codeowners.backend;
 
+import static com.google.gerrit.plugins.codeowners.backend.CodeOwnersChangeMessageUtil.appendPaths;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.PLUGIN;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.collect.ImmutableList;
@@ -27,7 +30,6 @@
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
 import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
-import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.WorkQueue;
@@ -36,6 +38,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
@@ -46,7 +49,6 @@
 import java.time.Instant;
 import java.util.List;
 import java.util.Optional;
-import java.util.stream.Stream;
 
 /**
  * Callback that is invoked when a user is added as a reviewer.
@@ -157,7 +159,9 @@
               "addCodeOwnersMessageOnAddReviewer",
               updateFactory -> {
                 try (BatchUpdate batchUpdate =
-                    updateFactory.create(projectName, currentUser, when)) {
+                        updateFactory.create(projectName, currentUser, when);
+                    RefUpdateContext pluginCtx = RefUpdateContext.open(PLUGIN);
+                    RefUpdateContext changeCtx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
                   batchUpdate.addOp(changeId, new Op(reviewers, maxPathsInChangeMessages));
                   batchUpdate.execute();
                 }
@@ -244,10 +248,5 @@
 
       return Optional.of(message.toString());
     }
-
-    private void appendPaths(StringBuilder message, Stream<Path> pathsToAppend) {
-      pathsToAppend.forEach(
-          path -> message.append(String.format("* %s\n", JgitPath.of(path).get())));
-    }
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java
index ee93f09..ab700ec 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java
@@ -15,27 +15,46 @@
 package com.google.gerrit.plugins.codeowners.backend;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.gerrit.plugins.codeowners.backend.CodeOwnersChangeMessageUtil.appendPaths;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.PLUGIN;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
 import com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval;
 import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
-import com.google.gerrit.plugins.codeowners.util.JgitPath;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.restapi.change.OnPostReview;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.LabelVote;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.nio.file.Path;
+import java.time.Instant;
 import java.util.Map;
 import java.util.Optional;
-import java.util.stream.Stream;
 
 /**
  * Callback that is invoked on post review and that extends the change message if a code owner
@@ -51,25 +70,47 @@
  * </ul>
  */
 @Singleton
-class OnCodeOwnerApproval implements OnPostReview {
+class OnCodeOwnerApproval implements OnPostReview, CommentAddedListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final String TAG_ADD_CODE_OWNER_APPROVAL =
+      ChangeMessagesUtil.AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "code-owners:addCodeOwnerApproval";
+
+  private final WorkQueue workQueue;
+  private final OneOffRequestContext oneOffRequestContext;
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
   private final CodeOwnerApprovalCheck codeOwnerApprovalCheck;
   private final CodeOwnerMetrics codeOwnerMetrics;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeMessagesUtil changeMessageUtil;
+  private final Provider<CurrentUser> userProvider;
+  private final RetryHelper retryHelper;
 
   @Inject
   OnCodeOwnerApproval(
+      WorkQueue workQueue,
+      OneOffRequestContext oneOffRequestContext,
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       CodeOwnerApprovalCheck codeOwnerApprovalCheck,
-      CodeOwnerMetrics codeOwnerMetrics) {
+      CodeOwnerMetrics codeOwnerMetrics,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeMessagesUtil changeMessageUtil,
+      Provider<CurrentUser> userProvider,
+      RetryHelper retryHelper) {
+    this.workQueue = workQueue;
+    this.oneOffRequestContext = oneOffRequestContext;
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.codeOwnerApprovalCheck = codeOwnerApprovalCheck;
     this.codeOwnerMetrics = codeOwnerMetrics;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeMessageUtil = changeMessageUtil;
+    this.userProvider = userProvider;
+    this.retryHelper = retryHelper;
   }
 
   @Override
   public Optional<String> getChangeMessageAddOn(
+      Instant when,
       IdentifiedUser user,
       ChangeNotes changeNotes,
       PatchSet patchSet,
@@ -80,11 +121,20 @@
     int maxPathsInChangeMessage = codeOwnersConfig.getMaxPathsInChangeMessages();
     if (codeOwnersConfig.isDisabled(changeNotes.getChange().getDest().branch())
         || maxPathsInChangeMessage <= 0) {
+      logger.atFine().log("skip extending the change message since message posting is disabled");
+      return Optional.empty();
+    }
+    if (codeOwnersConfig.enableAsyncMessageOnCodeOwnerApproval()) {
+      // To avoid adding latency to PostReview post the change message asynchronously from
+      // #onCommentAdded(Event) after PostReview is done.
+      logger.atFine().log(
+          "skip extending the change message since async message posting is enabled");
       return Optional.empty();
     }
 
     // code owner approvals are only computed for the current patch set
     if (!changeNotes.getChange().currentPatchSetId().equals(patchSet.id())) {
+      logger.atFine().log("skip extending the change message on non-current patch set");
       return Optional.empty();
     }
 
@@ -97,6 +147,7 @@
       return Optional.empty();
     }
 
+    logger.atFine().log("post change message synchronously");
     try (Timer0.Context ctx = codeOwnerMetrics.extendChangeMessageOnPostReview.start()) {
       return buildMessageForCodeOwnerApproval(
           user,
@@ -126,6 +177,127 @@
     }
   }
 
+  @Override
+  public void onCommentAdded(Event event) {
+    Project.NameKey projectName = Project.nameKey(event.getChange().project);
+    CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig =
+        codeOwnersPluginConfiguration.getProjectConfig(projectName);
+    int maxPathsInChangeMessage = codeOwnersConfig.getMaxPathsInChangeMessages();
+    if (codeOwnersConfig.isDisabled(event.getChange().branch) || maxPathsInChangeMessage <= 0) {
+      logger.atFine().log("skip posting the change message since message posting is disabled");
+      return;
+    }
+    if (!codeOwnersConfig.enableAsyncMessageOnCodeOwnerApproval()) {
+      // The change message has already been synchronously extended by #getChangeMessageAddOn(...).
+      logger.atFine().log(
+          "skip posting the change message since async message posting is disabled");
+      return;
+    }
+
+    // post change message asynchronously to avoid adding latency to PostReview
+    logger.atFine().log("schedule asynchronous posting of the change message");
+
+    CurrentUser user = userProvider.get();
+    if (!user.isIdentifiedUser()) {
+      // cannot compute owned paths for non-identified user
+      logger.atFine().log(
+          "skip posting the change message for non-identified user %s", user.getLoggableName());
+      return;
+    }
+
+    Change.Id changeId = Change.id(event.getChange()._number);
+    ChangeNotes changeNotes = changeNotesFactory.create(projectName, changeId);
+    PatchSet.Id patchSetId = PatchSet.id(changeId, event.getRevision()._number);
+    RequiredApproval requiredApproval = codeOwnersConfig.getRequiredApproval();
+
+    // code owner approvals are only computed for the current patch set
+    PatchSet currentPatchSet = changeNotes.getCurrentPatchSet();
+    if (!currentPatchSet.id().equals(patchSetId)) {
+      logger.atFine().log("skip posting the change message on non-current patch set");
+      return;
+    }
+
+    if (event.getOldApprovals().get(requiredApproval.labelType().getName()) == null) {
+      // If oldApprovals doesn't contain the label or if the labels value in it is null, the label
+      // was not changed.
+      // This means that the user only voted on unrelated labels.
+      return;
+    }
+
+    @SuppressWarnings("unused")
+    WorkQueue.Task<?> possiblyIgnoredError =
+        (WorkQueue.Task<?>)
+            workQueue
+                .getDefaultQueue()
+                .submit(
+                    () -> {
+                      try (ManualRequestContext ignored =
+                          oneOffRequestContext.openAs(user.getAccountId())) {
+                        postChangeMessage(
+                            event.getWhen(),
+                            user.asIdentifiedUser(),
+                            changeNotes,
+                            currentPatchSet,
+                            mapApprovalInfosToVotingValues(event.getOldApprovals()),
+                            mapApprovalInfosToVotingValues(event.getApprovals()),
+                            requiredApproval,
+                            maxPathsInChangeMessage);
+                      }
+                    });
+  }
+
+  private void postChangeMessage(
+      Instant when,
+      IdentifiedUser user,
+      ChangeNotes changeNotes,
+      PatchSet patchSet,
+      Map<String, Short> oldApprovals,
+      Map<String, Short> approvals,
+      RequiredApproval requiredApproval,
+      int maxPathsInChangeMessages) {
+    try (Timer0.Context ctx = codeOwnerMetrics.addChangeMessageOnCodeOwnerApproval.start()) {
+      retryHelper
+          .changeUpdate(
+              "addCodeOwnersMessageOnCodeOwnerApproval",
+              updateFactory -> {
+                try (BatchUpdate batchUpdate =
+                        updateFactory.create(changeNotes.getProjectName(), user, when);
+                    RefUpdateContext pluginCtx = RefUpdateContext.open(PLUGIN);
+                    RefUpdateContext changeCtx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+                  batchUpdate.addOp(
+                      changeNotes.getChangeId(),
+                      new Op(
+                          user,
+                          changeNotes,
+                          patchSet,
+                          oldApprovals,
+                          approvals,
+                          requiredApproval,
+                          maxPathsInChangeMessages));
+                  batchUpdate.execute();
+                }
+                return null;
+              })
+          .call();
+    } catch (Exception e) {
+      Optional<? extends Exception> configurationError =
+          CodeOwnersExceptionHook.getCauseOfConfigurationError(e);
+      if (configurationError.isPresent()) {
+        logger.atWarning().log(
+            "Failed to post code-owners change message for code owner approval on change %s in"
+                + " project %s: %s",
+            changeNotes.getChangeId(),
+            changeNotes.getProjectName(),
+            configurationError.get().getMessage());
+      } else {
+        logger.atSevere().withCause(e).log(
+            "Failed to post code-owners change message for code owner approval on change %s in"
+                + " project %s.",
+            changeNotes.getChangeId(), changeNotes.getProjectName());
+      }
+    }
+  }
+
   private Optional<String> buildMessageForCodeOwnerApproval(
       IdentifiedUser user,
       ChangeNotes changeNotes,
@@ -250,10 +422,6 @@
     return Optional.of(message.toString());
   }
 
-  private void appendPaths(StringBuilder message, Stream<Path> pathsToAppend) {
-    pathsToAppend.forEach(path -> message.append(String.format("* %s\n", JgitPath.of(path).get())));
-  }
-
   private boolean isIgnoredDueToSelfApproval(
       IdentifiedUser user, PatchSet patchSet, RequiredApproval requiredApproval) {
     return patchSet.uploader().equals(user.getAccountId())
@@ -290,4 +458,54 @@
         approvals);
     return LabelVote.create(labelName, approvals.get(labelName));
   }
+
+  private static ImmutableMap<String, Short> mapApprovalInfosToVotingValues(
+      Map<String, ApprovalInfo> approvals) {
+    return approvals.entrySet().stream()
+        .collect(
+            toImmutableMap(
+                Map.Entry::getKey,
+                e -> e.getValue().value != null ? e.getValue().value.shortValue() : null));
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final IdentifiedUser user;
+    private final ChangeNotes changeNotes;
+    private final PatchSet patchSet;
+    private final Map<String, Short> oldApprovals;
+    private final Map<String, Short> approvals;
+    private final RequiredApproval requiredApproval;
+    private final int limit;
+
+    Op(
+        IdentifiedUser user,
+        ChangeNotes changeNotes,
+        PatchSet patchSet,
+        Map<String, Short> oldApprovals,
+        Map<String, Short> approvals,
+        RequiredApproval requiredApproval,
+        int limit) {
+      this.user = user;
+      this.changeNotes = changeNotes;
+      this.patchSet = patchSet;
+      this.oldApprovals = oldApprovals;
+      this.approvals = approvals;
+      this.requiredApproval = requiredApproval;
+      this.limit = limit;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      Optional<String> message =
+          buildMessageForCodeOwnerApproval(
+              user, changeNotes, patchSet, oldApprovals, approvals, requiredApproval, limit);
+
+      if (message.isEmpty()) {
+        return false;
+      }
+
+      changeMessageUtil.setChangeMessage(ctx, message.get(), TAG_ADD_CODE_OWNER_APPROVAL);
+      return true;
+    }
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerOverride.java b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerOverride.java
index ea9fa59..270b1ed 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerOverride.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerOverride.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -60,6 +61,7 @@
 
   @Override
   public Optional<String> getChangeMessageAddOn(
+      Instant when,
       IdentifiedUser user,
       ChangeNotes changeNotes,
       PatchSet patchSet,
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
index a61c913..5e7a11f 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
@@ -252,17 +252,18 @@
       }
 
       // Resolve global imports.
-      ImmutableList.Builder<UnresolvedImport> unresolvedImports = ImmutableList.builder();
-      ImmutableSet<CodeOwnerConfigImport> globalImports = getGlobalImports(0, codeOwnerConfig);
-      OptionalResultWithMessages<List<UnresolvedImport>> unresolvedGlobalImports;
+      ImmutableList.Builder<CodeOwnerConfigImport> resolvedImports = ImmutableList.builder();
+      ImmutableList.Builder<CodeOwnerConfigImport> unresolvedImports = ImmutableList.builder();
+      ImmutableSet<CodeOwnerImport> globalImports = getGlobalImports(0, codeOwnerConfig);
+      OptionalResultWithMessages<CodeOwnerConfigImports> globalImportedCodeOwnerConfigs;
       if (!globalCodeOwnersIgnored) {
-        unresolvedGlobalImports =
+        globalImportedCodeOwnerConfigs =
             resolveImports(codeOwnerConfig.key(), globalImports, resolvedCodeOwnerConfigBuilder);
       } else {
         // skip global import with mode GLOBAL_CODE_OWNER_SETS_ONLY,
         // since we already know that global code owners will be ignored, we do not need to resolve
         // these imports
-        unresolvedGlobalImports =
+        globalImportedCodeOwnerConfigs =
             resolveImports(
                 codeOwnerConfig.key(),
                 globalImports.stream()
@@ -273,8 +274,9 @@
                     .collect(toImmutableSet()),
                 resolvedCodeOwnerConfigBuilder);
       }
-      messages.addAll(unresolvedGlobalImports.messages());
-      unresolvedImports.addAll(unresolvedGlobalImports.get());
+      messages.addAll(globalImportedCodeOwnerConfigs.messages());
+      resolvedImports.addAll(globalImportedCodeOwnerConfigs.get().resolved());
+      unresolvedImports.addAll(globalImportedCodeOwnerConfigs.get().unresolved());
 
       // Remove all global code owner sets if any per-file code owner set has the
       // ignoreGlobalAndParentCodeOwners flag set to true (as in this case they are ignored and
@@ -317,18 +319,22 @@
       }
 
       // Resolve per-file imports.
-      ImmutableSet<CodeOwnerConfigImport> perFileImports =
+      ImmutableSet<CodeOwnerImport> perFileImports =
           getPerFileImports(
               0, codeOwnerConfig.key(), resolvedCodeOwnerConfigBuilder.codeOwnerSets());
-      OptionalResultWithMessages<List<UnresolvedImport>> unresolvedPerFileImports =
+      OptionalResultWithMessages<CodeOwnerConfigImports> perFileImportedCodeOwnerConfigs =
           resolveImports(codeOwnerConfig.key(), perFileImports, resolvedCodeOwnerConfigBuilder);
-      messages.addAll(unresolvedPerFileImports.messages());
-      unresolvedImports.addAll(unresolvedPerFileImports.get());
+      messages.addAll(perFileImportedCodeOwnerConfigs.messages());
+      resolvedImports.addAll(perFileImportedCodeOwnerConfigs.get().resolved());
+      unresolvedImports.addAll(perFileImportedCodeOwnerConfigs.get().unresolved());
 
       this.pathCodeOwnersResult =
           OptionalResultWithMessages.create(
               PathCodeOwnersResult.create(
-                  path, resolvedCodeOwnerConfigBuilder.build(), unresolvedImports.build()),
+                  path,
+                  resolvedCodeOwnerConfigBuilder.build(),
+                  resolvedImports.build(),
+                  unresolvedImports.build()),
               messages);
       logger.atFine().log("path code owners result = %s", pathCodeOwnersResult);
       return this.pathCodeOwnersResult;
@@ -343,11 +349,12 @@
    * @param resolvedCodeOwnerConfigBuilder the builder for the resolved code owner config
    * @return list of unresolved imports, empty list if all imports were successfully resolved
    */
-  private OptionalResultWithMessages<List<UnresolvedImport>> resolveImports(
+  private OptionalResultWithMessages<CodeOwnerConfigImports> resolveImports(
       CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig,
-      Set<CodeOwnerConfigImport> codeOwnerConfigImports,
+      Set<CodeOwnerImport> codeOwnerConfigImports,
       CodeOwnerConfig.Builder resolvedCodeOwnerConfigBuilder) {
-    ImmutableList.Builder<UnresolvedImport> unresolvedImports = ImmutableList.builder();
+    ImmutableList.Builder<CodeOwnerConfigImport> resolvedImports = ImmutableList.builder();
+    ImmutableList.Builder<CodeOwnerConfigImport> unresolvedImports = ImmutableList.builder();
     StringBuilder messageBuilder = new StringBuilder();
     try (Timer0.Context ctx = codeOwnerMetrics.resolveCodeOwnerConfigImports.start()) {
       logger.atFine().log("resolve imports of codeOwnerConfig %s", keyOfImportingCodeOwnerConfig);
@@ -361,7 +368,7 @@
       Map<BranchNameKey, ObjectId> revisionMap = new HashMap<>();
       revisionMap.put(codeOwnerConfig.key().branchNameKey(), codeOwnerConfig.revision());
 
-      Queue<CodeOwnerConfigImport> codeOwnerConfigsToImport = new ArrayDeque<>();
+      Queue<CodeOwnerImport> codeOwnerConfigsToImport = new ArrayDeque<>();
       codeOwnerConfigsToImport.addAll(codeOwnerConfigImports);
       if (!codeOwnerConfigsToImport.isEmpty()) {
         messageBuilder.append(
@@ -370,7 +377,7 @@
                 keyOfImportingCodeOwnerConfig.format(codeOwners)));
       }
       while (!codeOwnerConfigsToImport.isEmpty()) {
-        CodeOwnerConfigImport codeOwnerConfigImport = codeOwnerConfigsToImport.poll();
+        CodeOwnerImport codeOwnerConfigImport = codeOwnerConfigsToImport.poll();
         messageBuilder.append(codeOwnerConfigImport.format());
 
         CodeOwnerConfigReference codeOwnerConfigReference =
@@ -387,7 +394,7 @@
               projectCache.get(keyOfImportedCodeOwnerConfig.project());
           if (!projectState.isPresent()) {
             unresolvedImports.add(
-                UnresolvedImport.create(
+                CodeOwnerConfigImport.createUnresolvedImport(
                     codeOwnerConfigImport.importingCodeOwnerConfig(),
                     keyOfImportedCodeOwnerConfig,
                     codeOwnerConfigReference,
@@ -399,7 +406,7 @@
           }
           if (!projectState.get().statePermitsRead()) {
             unresolvedImports.add(
-                UnresolvedImport.create(
+                CodeOwnerConfigImport.createUnresolvedImport(
                     codeOwnerConfigImport.importingCodeOwnerConfig(),
                     keyOfImportedCodeOwnerConfig,
                     codeOwnerConfigReference,
@@ -425,7 +432,7 @@
 
           if (!mayBeImportedCodeOwnerConfig.isPresent()) {
             unresolvedImports.add(
-                UnresolvedImport.create(
+                CodeOwnerConfigImport.createUnresolvedImport(
                     codeOwnerConfigImport.importingCodeOwnerConfig(),
                     keyOfImportedCodeOwnerConfig,
                     codeOwnerConfigReference,
@@ -439,6 +446,13 @@
           }
 
           CodeOwnerConfig importedCodeOwnerConfig = mayBeImportedCodeOwnerConfig.get();
+
+          resolvedImports.add(
+              CodeOwnerConfigImport.createResolvedImport(
+                  codeOwnerConfigImport.importingCodeOwnerConfig(),
+                  keyOfImportedCodeOwnerConfig,
+                  codeOwnerConfigReference));
+
           CodeOwnerConfigImportMode importMode = codeOwnerConfigReference.importMode();
           logger.atFine().log("import mode = %s", importMode.name());
 
@@ -475,7 +489,7 @@
           if (importMode.resolveImportsOfImport()
               && seenCodeOwnerConfigs.add(keyOfImportedCodeOwnerConfig)) {
             logger.atFine().log("resolve imports of imported code owner config");
-            Set<CodeOwnerConfigImport> transitiveImports = new HashSet<>();
+            Set<CodeOwnerImport> transitiveImports = new HashSet<>();
             transitiveImports.addAll(
                 getGlobalImports(codeOwnerConfigImport.importLevel() + 1, importedCodeOwnerConfig));
             transitiveImports.addAll(
@@ -495,7 +509,7 @@
                   transitiveImports.stream()
                       .map(
                           codeOwnerCfgImport ->
-                              CodeOwnerConfigImport.create(
+                              CodeOwnerImport.create(
                                   codeOwnerCfgImport.importLevel(),
                                   codeOwnerCfgImport.importingCodeOwnerConfig(),
                                   CodeOwnerConfigReference.copyWithNewImportMode(
@@ -516,31 +530,31 @@
       message = message.substring(0, message.length() - 1);
     }
     return OptionalResultWithMessages.create(
-        unresolvedImports.build(),
+        CodeOwnerConfigImports.create(resolvedImports.build(), unresolvedImports.build()),
         !message.isEmpty() ? ImmutableList.of(message) : ImmutableList.of());
   }
 
-  private ImmutableSet<CodeOwnerConfigImport> getGlobalImports(
+  private ImmutableSet<CodeOwnerImport> getGlobalImports(
       int importLevel, CodeOwnerConfig codeOwnerConfig) {
     return codeOwnerConfig.imports().stream()
         .map(
             codeOwnerConfigReference ->
-                CodeOwnerConfigImport.create(
+                CodeOwnerImport.create(
                     importLevel, codeOwnerConfig.key(), codeOwnerConfigReference))
         .collect(toImmutableSet());
   }
 
-  private ImmutableSet<CodeOwnerConfigImport> getPerFileImports(
+  private ImmutableSet<CodeOwnerImport> getPerFileImports(
       int importLevel,
       CodeOwnerConfig.Key importingCodeOwnerConfig,
       Set<CodeOwnerSet> codeOwnerSets) {
-    ImmutableSet.Builder<CodeOwnerConfigImport> codeOwnerConfigImports = ImmutableSet.builder();
+    ImmutableSet.Builder<CodeOwnerImport> codeOwnerConfigImports = ImmutableSet.builder();
     for (CodeOwnerSet codeOwnerSet : codeOwnerSets) {
       codeOwnerSet.imports().stream()
           .forEach(
               codeOwnerConfigReference ->
                   codeOwnerConfigImports.add(
-                      CodeOwnerConfigImport.create(
+                      CodeOwnerImport.create(
                           importLevel,
                           importingCodeOwnerConfig,
                           codeOwnerConfigReference,
@@ -623,7 +637,7 @@
   }
 
   @AutoValue
-  abstract static class CodeOwnerConfigImport {
+  abstract static class CodeOwnerImport {
     /**
      * The import level.
      *
@@ -683,7 +697,7 @@
       return levels > 0 ? String.format("%" + (levels * 2) + "s", "") : "";
     }
 
-    public static CodeOwnerConfigImport create(
+    public static CodeOwnerImport create(
         int importLevel,
         CodeOwnerConfig.Key importingCodeOwnerConfig,
         CodeOwnerConfigReference codeOwnerConfigReference) {
@@ -691,7 +705,7 @@
           importLevel, importingCodeOwnerConfig, codeOwnerConfigReference, Optional.empty());
     }
 
-    public static CodeOwnerConfigImport create(
+    public static CodeOwnerImport create(
         int importLevel,
         CodeOwnerConfig.Key importingCodeOwnerConfig,
         CodeOwnerConfigReference codeOwnerConfigReference,
@@ -703,13 +717,28 @@
           Optional.of(codeOwnerSet));
     }
 
-    public static CodeOwnerConfigImport create(
+    public static CodeOwnerImport create(
         int importLevel,
         CodeOwnerConfig.Key importingCodeOwnerConfig,
         CodeOwnerConfigReference codeOwnerConfigReference,
         Optional<CodeOwnerSet> codeOwnerSet) {
-      return new AutoValue_PathCodeOwners_CodeOwnerConfigImport(
+      return new AutoValue_PathCodeOwners_CodeOwnerImport(
           importLevel, importingCodeOwnerConfig, codeOwnerConfigReference, codeOwnerSet);
     }
   }
+
+  @AutoValue
+  abstract static class CodeOwnerConfigImports {
+    /** Imported code owner configs the could be resolved. */
+    abstract ImmutableList<CodeOwnerConfigImport> resolved();
+
+    /** Imported code owner configs the could not be resolved. */
+    abstract ImmutableList<CodeOwnerConfigImport> unresolved();
+
+    static CodeOwnerConfigImports create(
+        ImmutableList<CodeOwnerConfigImport> resolved,
+        ImmutableList<CodeOwnerConfigImport> unresolved) {
+      return new AutoValue_PathCodeOwners_CodeOwnerConfigImports(resolved, unresolved);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java
index 93a36be..5b262bf 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java
@@ -36,8 +36,11 @@
   /** Gets the resolved code owner config. */
   abstract CodeOwnerConfig codeOwnerConfig();
 
+  /** Gets a list of resolved imports. */
+  public abstract ImmutableList<CodeOwnerConfigImport> resolvedImports();
+
   /** Gets a list of unresolved imports. */
-  public abstract ImmutableList<UnresolvedImport> unresolvedImports();
+  public abstract ImmutableList<CodeOwnerConfigImport> unresolvedImports();
 
   /** Whether there are unresolved imports. */
   public boolean hasUnresolvedImports() {
@@ -104,14 +107,21 @@
     return MoreObjects.toStringHelper(this)
         .add("path", path())
         .add("codeOwnerConfig", codeOwnerConfig())
+        .add("resolvedImports", resolvedImports())
         .add("unresolvedImports", unresolvedImports())
         .toString();
   }
 
   /** Creates a {@link PathCodeOwnersResult} instance. */
   public static PathCodeOwnersResult create(
-      Path path, CodeOwnerConfig codeOwnerConfig, List<UnresolvedImport> unresolvedImports) {
+      Path path,
+      CodeOwnerConfig codeOwnerConfig,
+      List<CodeOwnerConfigImport> resolvedImports,
+      List<CodeOwnerConfigImport> unresolvedImports) {
     return new AutoValue_PathCodeOwnersResult(
-        path, codeOwnerConfig, ImmutableList.copyOf(unresolvedImports));
+        path,
+        codeOwnerConfig,
+        ImmutableList.copyOf(resolvedImports),
+        ImmutableList.copyOf(unresolvedImports));
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/UnresolvedImport.java b/java/com/google/gerrit/plugins/codeowners/backend/UnresolvedImport.java
deleted file mode 100644
index bea1e08..0000000
--- a/java/com/google/gerrit/plugins/codeowners/backend/UnresolvedImport.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// 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 com.google.auto.value.AutoValue;
-import com.google.common.base.MoreObjects;
-
-/** Information about an unresolved import. */
-@AutoValue
-public abstract class UnresolvedImport {
-  /** Key of the importing code owner config. */
-  public abstract CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig();
-
-  /** Key of the imported code owner config. */
-  public abstract CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig();
-
-  /** The code owner config reference that was attempted to be resolved. */
-  public abstract CodeOwnerConfigReference codeOwnerConfigReference();
-
-  /** Message explaining why the code owner config reference couldn't be resolved. */
-  public abstract String message();
-
-  @Override
-  public final String toString() {
-    return MoreObjects.toStringHelper(this)
-        .add("keyOfImportingCodeOwnerConfig", keyOfImportingCodeOwnerConfig())
-        .add("keyOfImportedCodeOwnerConfig", keyOfImportedCodeOwnerConfig())
-        .add("codeOwnerConfigReference", codeOwnerConfigReference())
-        .add("message", message())
-        .toString();
-  }
-
-  /** Creates a {@link UnresolvedImport} instance. */
-  static UnresolvedImport create(
-      CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig,
-      CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig,
-      CodeOwnerConfigReference codeOwnerConfigReference,
-      String message) {
-    return new AutoValue_UnresolvedImport(
-        keyOfImportingCodeOwnerConfig,
-        keyOfImportedCodeOwnerConfig,
-        codeOwnerConfigReference,
-        message);
-  }
-}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/UnresolvedImportFormatter.java b/java/com/google/gerrit/plugins/codeowners/backend/UnresolvedImportFormatter.java
index 52958d9..7946468 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/UnresolvedImportFormatter.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/UnresolvedImportFormatter.java
@@ -20,7 +20,7 @@
 import com.google.inject.Inject;
 import java.nio.file.Path;
 
-/** Class to format an {@link UnresolvedImport} as a user-readable string. */
+/** Class to format an {@link CodeOwnerConfigImport} as a user-readable string. */
 public class UnresolvedImportFormatter {
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
   private final ProjectCache projectCache;
@@ -37,7 +37,7 @@
   }
 
   /** Returns a user-readable string representation of the given unresolved import. */
-  public String format(UnresolvedImport unresolvedImport) {
+  public String format(CodeOwnerConfigImport unresolvedImport) {
     return String.format(
         "The import of %s:%s:%s in %s:%s:%s cannot be resolved: %s",
         unresolvedImport.keyOfImportedCodeOwnerConfig().project(),
@@ -46,10 +46,16 @@
         unresolvedImport.keyOfImportingCodeOwnerConfig().project(),
         unresolvedImport.keyOfImportingCodeOwnerConfig().shortBranchName(),
         getFilePath(unresolvedImport.keyOfImportingCodeOwnerConfig()),
-        unresolvedImport.message());
+        unresolvedImport
+            .errorMessage()
+            .orElseThrow(
+                () ->
+                    new IllegalStateException(
+                        String.format(
+                            "unresolved import %s must have an error message", unresolvedImport))));
   }
 
-  private Path getFilePath(CodeOwnerConfig.Key codeOwnerConfigKey) {
+  public Path getFilePath(CodeOwnerConfig.Key codeOwnerConfigKey) {
     return getBackend(codeOwnerConfigKey).getFilePath(codeOwnerConfigKey);
   }
 
@@ -60,6 +66,10 @@
    * returned.
    */
   private CodeOwnerBackend getBackend(CodeOwnerConfig.Key codeOwnerConfigKey) {
+    // For unresolved imports the project may not exist. Trying to get the project config for
+    // non-existing projects fails, hence check whether the project exists before trying to access
+    // the project config and fall back to the default code owner backend if the project doesn't
+    // exist.
     if (projectCache.get(codeOwnerConfigKey.project()).isPresent()) {
       return codeOwnersPluginConfiguration
           .getProjectConfig(codeOwnerConfigKey.project())
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfiguration.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfiguration.java
index a216eb1..cb565c5 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfiguration.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfiguration.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.cache.PerThreadCache;
+import com.google.gerrit.server.cache.PerThreadProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -69,8 +70,9 @@
    */
   public CodeOwnersPluginProjectConfigSnapshot getProjectConfig(Project.NameKey projectName) {
     requireNonNull(projectName, "projectName");
-    return PerThreadCache.getOrCompute(
-        PerThreadCache.Key.create(CodeOwnersPluginProjectConfigSnapshot.class, projectName),
+    return PerThreadProjectCache.getOrCompute(
+        PerThreadCache.Key.create(
+            Project.NameKey.class, projectName, "CodeOwnersPluginConfiguration"),
         () -> codeOwnersPluginProjectConfigSnapshotFactory.create(projectName));
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java
index f02e54d..786d3e4 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java
@@ -84,6 +84,7 @@
   @Nullable private FallbackCodeOwners fallbackCodeOwners;
   @Nullable private Integer maxPathsInChangeMessages;
   @Nullable private Boolean enableAsyncMessageOnAddReviewer;
+  @Nullable private Boolean enableAsyncMessageOnCodeOwnerApproval;
   @Nullable private ImmutableSet<CodeOwnerReference> globalCodeOwners;
   @Nullable private ImmutableSet<Account.Id> exemptedAccounts;
   @Nullable private Optional<String> overrideInfoUrl;
@@ -333,6 +334,18 @@
     return enableAsyncMessageOnAddReviewer;
   }
 
+  /**
+   * Gets whether applying a code owner approval should post a change message asynchronously instead
+   * of synchronously extending the change message that is posted by Gerrit core.
+   */
+  public boolean enableAsyncMessageOnCodeOwnerApproval() {
+    if (enableAsyncMessageOnCodeOwnerApproval == null) {
+      enableAsyncMessageOnCodeOwnerApproval =
+          generalConfig.enableAsyncMessageOnCodeOwnerApproval(projectName, pluginConfig);
+    }
+    return enableAsyncMessageOnCodeOwnerApproval;
+  }
+
   /** Gets the global code owners. */
   public ImmutableSet<CodeOwnerReference> getGlobalCodeOwners() {
     if (globalCodeOwners == null) {
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
index 810486f..ada9105 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
@@ -65,6 +65,8 @@
   public static final String KEY_FILE_EXTENSION = "fileExtension";
   public static final String KEY_ENABLE_ASYNC_MESSAGE_ON_ADD_REVIEWER =
       "enableAsyncMessageOnAddReviewer";
+  public static final String KEY_ENABLE_ASYNC_MESSAGE_ON_CODE_OWNER_APPROVAL =
+      "enableAsyncMessageOnCodeOwnerApproval";
   public static final String KEY_ENABLE_CODE_OWNER_CONFIG_FILES_WITH_FILE_EXTENSIONS =
       "enableCodeOwnerConfigFilesWithFileExtensions";
   public static final String KEY_READ_ONLY = "readOnly";
@@ -476,6 +478,25 @@
   }
 
   /**
+   * Gets whether applying a code owner approval should post a change message asynchronously instead
+   * of synchronously extending the change message that is posted by Gerrit core.
+   *
+   * @param project the project for which the enable async message on code owner approval
+   *     configuration should be read
+   * @param pluginConfig the plugin config from which the enable async message on code owner
+   *     approval configuration should be read
+   * @return whether applying a code owner approval should post a change message asynchronously
+   *     instead of synchronously extending the change message that is posted by Gerrit core
+   */
+  boolean enableAsyncMessageOnCodeOwnerApproval(Project.NameKey project, Config pluginConfig) {
+    return getBooleanConfig(
+        project,
+        pluginConfig,
+        KEY_ENABLE_ASYNC_MESSAGE_ON_CODE_OWNER_APPROVAL,
+        /* defaultValue= */ true);
+  }
+
+  /**
    * Gets the enable validation on branch creation configuration from the given plugin config for
    * the specified project with fallback to {@code gerrit.config} and default to {@code true}.
    *
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
index 513bd12..941a31c 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -47,12 +48,14 @@
       CodeOwnerConfigFile.Factory codeOwnerConfigFileFactory,
       FindOwnersCodeOwnerConfigParser codeOwnerConfigParser,
       GitRepositoryManager repoManager,
+      ProjectCache projectCache,
       @GerritPersonIdent PersonIdent serverIdent,
       MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
       RetryHelper retryHelper) {
     super(
         codeOwnersPluginConfiguration,
         repoManager,
+        projectCache,
         serverIdent,
         metaDataUpdateInternalFactory,
         retryHelper,
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackend.java
index ccb59df..a761bf6 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackend.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -49,6 +50,7 @@
   ProtoBackend(
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       GitRepositoryManager repoManager,
+      ProjectCache projectCache,
       @GerritPersonIdent PersonIdent serverIdent,
       MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
       RetryHelper retryHelper,
@@ -57,6 +59,7 @@
     super(
         codeOwnersPluginConfiguration,
         repoManager,
+        projectCache,
         serverIdent,
         metaDataUpdateInternalFactory,
         retryHelper,
diff --git a/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java b/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java
index fa90564..4a6c37d 100644
--- a/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java
+++ b/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java
@@ -33,6 +33,7 @@
 public class CodeOwnerMetrics {
   // latency metrics
   public final Timer1<String> addChangeMessageOnAddReviewer;
+  public final Timer0 addChangeMessageOnCodeOwnerApproval;
   public final Timer0 computeFileStatus;
   public final Timer1<Boolean> computeFileStatuses;
   public final Timer0 computeOwnedPaths;
@@ -85,6 +86,11 @@
                 .description(
                     "Whether the change message was posted synchronously or asynchronously.")
                 .build());
+    this.addChangeMessageOnCodeOwnerApproval =
+        createTimer(
+            "add_change_message_on_code_owner_approval",
+            "Latency for asynchronously adding a change message with the owned path when a code"
+                + " owner approval is applied");
     this.computeFileStatus =
         createTimer("compute_file_status", "Latency for computing the file status of one file");
     this.computeFileStatuses =
@@ -105,8 +111,8 @@
     this.extendChangeMessageOnPostReview =
         createTimer(
             "extend_change_message_on_post_review",
-            "Latency for extending the change message with the owned path when a code owner"
-                + " approval is applied");
+            "Latency for synchronously extending the change message with the owned path when a"
+                + " code owner approval is applied");
     this.getChangedFiles =
         createTimer("get_changed_files", "Latency for getting changed files from diff cache");
     this.prepareFileStatusComputation =
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java b/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java
index 677e67c..0fbb01d 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigFileInfo;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnersInfo;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwner;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerAnnotation;
@@ -92,6 +93,7 @@
   private final CodeOwnerConfigHierarchy codeOwnerConfigHierarchy;
   private final Provider<CodeOwnerResolver> codeOwnerResolver;
   private final CodeOwnerJson.Factory codeOwnerJsonFactory;
+  private final CodeOwnerConfigFileJson codeOwnerConfigFileJson;
   private final EnumSet<ListAccountsOption> options;
   private final Set<String> hexOptions;
 
@@ -167,7 +169,8 @@
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
       Provider<CodeOwnerResolver> codeOwnerResolver,
-      CodeOwnerJson.Factory codeOwnerJsonFactory) {
+      CodeOwnerJson.Factory codeOwnerJsonFactory,
+      CodeOwnerConfigFileJson codeOwnerConfigFileJson) {
     this.accountVisibility = accountVisibility;
     this.accounts = accounts;
     this.accountControlFactory = accountControlFactory;
@@ -178,6 +181,7 @@
     this.codeOwnerConfigHierarchy = codeOwnerConfigHierarchy;
     this.codeOwnerResolver = codeOwnerResolver;
     this.codeOwnerJsonFactory = codeOwnerJsonFactory;
+    this.codeOwnerConfigFileJson = codeOwnerConfigFileJson;
     this.options = EnumSet.noneOf(ListAccountsOption.class);
     this.hexOptions = new HashSet<>();
   }
@@ -213,6 +217,8 @@
     ListMultimap<CodeOwner, CodeOwnerAnnotation> annotations = LinkedListMultimap.create();
     AtomicBoolean ownedByAllUsers = new AtomicBoolean(false);
     ImmutableList.Builder<String> debugLogsBuilder = ImmutableList.builder();
+    ImmutableList.Builder<CodeOwnerConfigFileInfo> codeOwnerConfigFileInfosBuilder =
+        ImmutableList.builder();
     codeOwnerConfigHierarchy.visit(
         rsrc.getBranch(),
         rsrc.getRevision(),
@@ -221,6 +227,12 @@
           CodeOwnerResolverResult pathCodeOwners =
               codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, rsrc.getPath());
 
+          codeOwnerConfigFileInfosBuilder.add(
+              codeOwnerConfigFileJson.format(
+                  codeOwnerConfig.key(),
+                  pathCodeOwners.resolvedImports(),
+                  pathCodeOwners.unresolvedImports()));
+
           debugLogsBuilder.addAll(pathCodeOwners.messages());
           codeOwners.addAll(pathCodeOwners.codeOwners());
           annotations.putAll(pathCodeOwners.annotations());
@@ -308,7 +320,7 @@
     ImmutableMap<CodeOwner, Double> scoredCodeOwners =
         codeOwnerScorings.getScorings(filteredCodeOwners);
 
-    ImmutableList<CodeOwner> sortedAndLimitedCodeOwners = sortAndLimit(rsrc, scoredCodeOwners);
+    ImmutableList<CodeOwner> sortedAndLimitedCodeOwners = sortAndLimit(scoredCodeOwners);
 
     if (highestScoreOnly) {
       Optional<Double> highestScore =
@@ -325,7 +337,7 @@
     codeOwnersInfo.codeOwners =
         codeOwnerJsonFactory.create(getFillOptions()).format(sortedAndLimitedCodeOwners);
     codeOwnersInfo.ownedByAllUsers = ownedByAllUsers.get() ? true : null;
-
+    codeOwnersInfo.codeOwnerConfigs = codeOwnerConfigFileInfosBuilder.build();
     ImmutableList<String> debugLogs = debugLogsBuilder.build();
     codeOwnersInfo.debugLogs = debug ? debugLogs : null;
     logger.atFine().log("debug logs: %s", debugLogs);
@@ -473,16 +485,15 @@
     if (options.contains(ListAccountsOption.ALL_EMAILS)) {
       // Secondary emails are only visible to users that have the 'Modify Account' global
       // capability.
-      permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+      permissionBackend.currentUser().check(GlobalPermission.VIEW_SECONDARY_EMAILS);
       fillOptions.add(FillOptions.EMAIL);
       fillOptions.add(FillOptions.SECONDARY_EMAILS);
     }
     return fillOptions;
   }
 
-  private ImmutableList<CodeOwner> sortAndLimit(
-      R rsrc, ImmutableMap<CodeOwner, Double> scoredCodeOwners) {
-    return sortCodeOwners(rsrc, seed, scoredCodeOwners).limit(limit).collect(toImmutableList());
+  private ImmutableList<CodeOwner> sortAndLimit(ImmutableMap<CodeOwner, Double> scoredCodeOwners) {
+    return sortCodeOwners(seed, scoredCodeOwners).limit(limit).collect(toImmutableList());
   }
 
   /**
@@ -492,13 +503,12 @@
    *
    * <p>The order of code owners with the same score is random.
    *
-   * @param rsrc resource on which this REST endpoint is invoked
    * @param seed seed that should be used to randomize the order
    * @param scoredCodeOwners the code owners with their scores
    * @return the sorted code owners
    */
   private Stream<CodeOwner> sortCodeOwners(
-      R rsrc, Optional<Long> seed, ImmutableMap<CodeOwner, Double> scoredCodeOwners) {
+      Optional<Long> seed, ImmutableMap<CodeOwner, Double> scoredCodeOwners) {
     return randomizeOrder(seed, scoredCodeOwners.keySet())
         .sorted(Comparator.comparingDouble(scoredCodeOwners::get).reversed());
   }
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerConfigFileJson.java b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerConfigFileJson.java
new file mode 100644
index 0000000..fe17a3f
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerConfigFileJson.java
@@ -0,0 +1,119 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.restapi;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigFileInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImport;
+import com.google.gerrit.plugins.codeowners.backend.UnresolvedImportFormatter;
+import com.google.inject.Inject;
+import java.util.List;
+
+/** Collection of routines to populate {@link CodeOwnerConfigFileInfo}. */
+public class CodeOwnerConfigFileJson {
+  private final UnresolvedImportFormatter unresolvedImportFormatter;
+
+  @Inject
+  CodeOwnerConfigFileJson(UnresolvedImportFormatter unresolvedImportFormatter) {
+    this.unresolvedImportFormatter = unresolvedImportFormatter;
+  }
+
+  /**
+   * Formats the provided code owner config file information as a {@link CodeOwnerConfigFileInfo}.
+   *
+   * @param codeOwnerConfigKey the key of the code owner config file as {@link
+   *     CodeOwnerConfigFileInfo}
+   * @param resolvedImports code owner config files which have been successfully imported directly
+   *     or indirectly
+   * @param unresolvedImports code owner config files which are imported directly or indirectly but
+   *     couldn't be resolved
+   * @return the provided {@link com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig.Key}
+   *     as {@link CodeOwnerConfigFileInfo}
+   */
+  public CodeOwnerConfigFileInfo format(
+      CodeOwnerConfig.Key codeOwnerConfigKey,
+      List<CodeOwnerConfigImport> resolvedImports,
+      List<CodeOwnerConfigImport> unresolvedImports) {
+    requireNonNull(codeOwnerConfigKey, "codeOwnerConfigKey");
+    requireNonNull(resolvedImports, "resolvedImports");
+    requireNonNull(unresolvedImports, "unresolvedImports");
+
+    CodeOwnerConfigFileInfo info = new CodeOwnerConfigFileInfo();
+
+    info.project = codeOwnerConfigKey.project().get();
+    info.branch = codeOwnerConfigKey.branchNameKey().branch();
+    info.path = unresolvedImportFormatter.getFilePath(codeOwnerConfigKey).toString();
+
+    ImmutableList<CodeOwnerConfigFileInfo> unresolvedImportInfos =
+        unresolvedImports.stream()
+            .filter(
+                unresolvedImport ->
+                    unresolvedImport.keyOfImportingCodeOwnerConfig().equals(codeOwnerConfigKey))
+            .map(
+                unresolvedImport -> {
+                  CodeOwnerConfigFileInfo unresolvedCodeOwnerConfigFileInfo =
+                      format(
+                          unresolvedImport.keyOfImportedCodeOwnerConfig(),
+                          /* resolvedImports= */ ImmutableList.of(),
+                          /* unresolvedImports= */ ImmutableList.of());
+                  unresolvedCodeOwnerConfigFileInfo.importMode =
+                      unresolvedImport.codeOwnerConfigReference().importMode();
+                  unresolvedCodeOwnerConfigFileInfo.unresolvedErrorMessage =
+                      unresolvedImport
+                          .errorMessage()
+                          .orElseThrow(
+                              () ->
+                                  new IllegalStateException(
+                                      String.format(
+                                          "unresolved import %s must have an error message",
+                                          unresolvedImport)));
+                  return unresolvedCodeOwnerConfigFileInfo;
+                })
+            .collect(toImmutableList());
+    info.unresolvedImports = !unresolvedImportInfos.isEmpty() ? unresolvedImportInfos : null;
+
+    ImmutableList<CodeOwnerConfigFileInfo> importInfos =
+        resolvedImports.stream()
+            .filter(
+                resolvedImport ->
+                    resolvedImport.keyOfImportingCodeOwnerConfig().equals(codeOwnerConfigKey))
+            .map(
+                resolvedImport -> {
+                  CodeOwnerConfigFileInfo resolvedCodeOwnerConfigFileInfo =
+                      format(
+                          resolvedImport.keyOfImportedCodeOwnerConfig(),
+                          removeImportEntriesFor(resolvedImports, codeOwnerConfigKey),
+                          removeImportEntriesFor(unresolvedImports, codeOwnerConfigKey));
+                  resolvedCodeOwnerConfigFileInfo.importMode =
+                      resolvedImport.codeOwnerConfigReference().importMode();
+                  return resolvedCodeOwnerConfigFileInfo;
+                })
+            .collect(toImmutableList());
+    info.imports = !importInfos.isEmpty() ? importInfos : null;
+
+    return info;
+  }
+
+  private ImmutableList<CodeOwnerConfigImport> removeImportEntriesFor(
+      List<CodeOwnerConfigImport> imports, CodeOwnerConfig.Key codeOwnerConfigKey) {
+    return imports.stream()
+        .filter(i -> !i.keyOfImportingCodeOwnerConfig().equals(codeOwnerConfigKey))
+        .collect(toImmutableList());
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInBranch.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInBranch.java
index e0a9145..85410e9 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInBranch.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInBranch.java
@@ -82,6 +82,7 @@
       CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
       Provider<CodeOwnerResolver> codeOwnerResolver,
       CodeOwnerJson.Factory codeOwnerJsonFactory,
+      CodeOwnerConfigFileJson codeOwnerConfigFileJson,
       GitRepositoryManager repoManager) {
     super(
         accountVisibility,
@@ -93,7 +94,8 @@
         codeOwnersPluginConfiguration,
         codeOwnerConfigHierarchy,
         codeOwnerResolver,
-        codeOwnerJsonFactory);
+        codeOwnerJsonFactory,
+        codeOwnerConfigFileJson);
     this.repoManager = repoManager;
   }
 
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java
index 3ed9a51..b7149c5 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java
@@ -73,7 +73,8 @@
       CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
       Provider<CodeOwnerResolver> codeOwnerResolver,
       ServiceUserClassifier serviceUserClassifier,
-      CodeOwnerJson.Factory codeOwnerJsonFactory) {
+      CodeOwnerJson.Factory codeOwnerJsonFactory,
+      CodeOwnerConfigFileJson codeOwnerConfigFileJson) {
     super(
         accountVisibility,
         accounts,
@@ -84,7 +85,8 @@
         codeOwnersPluginConfiguration,
         codeOwnerConfigHierarchy,
         codeOwnerResolver,
-        codeOwnerJsonFactory);
+        codeOwnerJsonFactory,
+        codeOwnerConfigFileJson);
     this.serviceUserClassifier = serviceUserClassifier;
   }
 
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerConfigFileInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerConfigFileInfoSubject.java
new file mode 100644
index 0000000..4d09a10
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerConfigFileInfoSubject.java
@@ -0,0 +1,188 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.truth.ListSubject.elements;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigFileInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportMode;
+import com.google.gerrit.truth.ListSubject;
+
+/** {@link Subject} for doing assertions on {@link CodeOwnerConfigFileInfo}s. */
+public class CodeOwnerConfigFileInfoSubject extends Subject {
+  /**
+   * Starts fluent chain to do assertions on a {@link CodeOwnerConfigFileInfo}.
+   *
+   * @param codeOwnerConfigFileInfo the code owner config file info on which assertions should be
+   *     done
+   * @return the created {@link CodeOwnerConfigFileInfoSubject}
+   */
+  public static CodeOwnerConfigFileInfoSubject assertThat(
+      CodeOwnerConfigFileInfo codeOwnerConfigFileInfo) {
+    return assertAbout(codeOwnerConfigFileInfos()).that(codeOwnerConfigFileInfo);
+  }
+
+  public static Factory<CodeOwnerConfigFileInfoSubject, CodeOwnerConfigFileInfo>
+      codeOwnerConfigFileInfos() {
+    return CodeOwnerConfigFileInfoSubject::new;
+  }
+
+  private final CodeOwnerConfigFileInfo codeOwnerConfigFileInfo;
+
+  private CodeOwnerConfigFileInfoSubject(
+      FailureMetadata metadata, CodeOwnerConfigFileInfo codeOwnerConfigFileInfo) {
+    super(metadata, codeOwnerConfigFileInfo);
+    this.codeOwnerConfigFileInfo = codeOwnerConfigFileInfo;
+  }
+
+  /** Returns a subject for the project of the code owner config file info. */
+  public StringSubject hasProjectThat() {
+    return check("project()").that(codeOwnerConfigFileInfo().project);
+  }
+
+  /** Returns a subject for the branch of the code owner config file info. */
+  public StringSubject hasBranchThat() {
+    return check("branch()").that(codeOwnerConfigFileInfo().branch);
+  }
+
+  /** Returns a subject for the path of the code owner config file info. */
+  public StringSubject hasPathThat() {
+    return check("path()").that(codeOwnerConfigFileInfo().path);
+  }
+
+  /**
+   * Returns a {@link ListSubject} for the (resolved) imports of the code owner config file info.
+   */
+  public ListSubject<CodeOwnerConfigFileInfoSubject, CodeOwnerConfigFileInfo> hasImportsThat() {
+    return check("imports()")
+        .about(elements())
+        .thatCustom(codeOwnerConfigFileInfo().imports, codeOwnerConfigFileInfos());
+  }
+
+  /**
+   * Returns a {@link ListSubject} for the unresolved imports of the code owner config file info.
+   */
+  public ListSubject<CodeOwnerConfigFileInfoSubject, CodeOwnerConfigFileInfo>
+      hasUnresolvedImportsThat() {
+    return check("unresolvedimports()")
+        .about(elements())
+        .thatCustom(codeOwnerConfigFileInfo().unresolvedImports, codeOwnerConfigFileInfos());
+  }
+
+  /** Returns a subject for the import mode of the code owner config file info. */
+  public Subject hasImportModeThat() {
+    return check("importMode()").that(codeOwnerConfigFileInfo().importMode);
+  }
+
+  /** Returns a subject for the unresolved error message of the code owner config file info. */
+  public Subject hasUnresolvedErrorMessageThat() {
+    return check("unresolvedErrorMessage()").that(codeOwnerConfigFileInfo().unresolvedErrorMessage);
+  }
+
+  @CanIgnoreReturnValue
+  public CodeOwnerConfigFileInfoSubject assertKey(
+      CodeOwnerBackend codeOwnerBackend, CodeOwnerConfig.Key codeOwnerConfigKey) {
+    hasProjectThat().isEqualTo(codeOwnerConfigKey.project().get());
+    hasBranchThat().isEqualTo(codeOwnerConfigKey.branchNameKey().branch());
+    hasPathThat().isEqualTo(codeOwnerBackend.getFilePath(codeOwnerConfigKey).toString());
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public CodeOwnerConfigFileInfoSubject assertNoResolvedImports() {
+    hasImportsThat().isNull();
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public CodeOwnerConfigFileInfoSubject assertResolvedImport(
+      CodeOwnerBackend codeOwnerBackend,
+      CodeOwnerConfig.Key codeOwnerConfigKey,
+      CodeOwnerConfigImportMode importMode) {
+    hasImportsThat().hasSize(1);
+    CodeOwnerConfigFileInfoSubject subjectForResolvedImport = hasImportsThat().element(0);
+    subjectForResolvedImport
+        .assertKey(codeOwnerBackend, codeOwnerConfigKey)
+        .assertImportMode(importMode)
+        .assertNoUnresolvedErrorMessage();
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public CodeOwnerConfigFileInfoSubject assertNoUnresolvedImports() {
+    hasUnresolvedImportsThat().isNull();
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public CodeOwnerConfigFileInfoSubject assertUnresolvedImport(
+      CodeOwnerBackend codeOwnerBackend,
+      CodeOwnerConfig.Key codeOwnerConfigKey,
+      CodeOwnerConfigImportMode importMode,
+      String unresolvedErrorMessage) {
+    hasUnresolvedImportsThat().hasSize(1);
+    CodeOwnerConfigFileInfoSubject subjectForUnresolvedImport =
+        hasUnresolvedImportsThat().element(0);
+    subjectForUnresolvedImport
+        .assertKey(codeOwnerBackend, codeOwnerConfigKey)
+        .assertImportMode(importMode)
+        .assertUnresolvedErrorMessage(unresolvedErrorMessage);
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public CodeOwnerConfigFileInfoSubject assertNoImports() {
+    assertNoResolvedImports();
+    assertNoUnresolvedImports();
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public CodeOwnerConfigFileInfoSubject assertImportMode(CodeOwnerConfigImportMode importMode) {
+    hasImportModeThat().isEqualTo(importMode);
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public CodeOwnerConfigFileInfoSubject assertNoImportMode() {
+    hasImportModeThat().isNull();
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public CodeOwnerConfigFileInfoSubject assertUnresolvedErrorMessage(
+      String unresolvedErrorMessage) {
+    hasUnresolvedErrorMessageThat().isEqualTo(unresolvedErrorMessage);
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public CodeOwnerConfigFileInfoSubject assertNoUnresolvedErrorMessage() {
+    hasUnresolvedErrorMessageThat().isNull();
+    return this;
+  }
+
+  private CodeOwnerConfigFileInfo codeOwnerConfigFileInfo() {
+    isNotNull();
+    return codeOwnerConfigFileInfo;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnersInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnersInfoSubject.java
index 11433e2..9e5fb44 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnersInfoSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnersInfoSubject.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.plugins.codeowners.testing;
 
 import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerConfigFileInfoSubject.codeOwnerConfigFileInfos;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerInfoSubject.codeOwnerInfos;
 import static com.google.gerrit.truth.ListSubject.elements;
 
@@ -22,6 +23,7 @@
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.IterableSubject;
 import com.google.common.truth.Subject;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigFileInfo;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerInfo;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnersInfo;
 import com.google.gerrit.truth.ListSubject;
@@ -65,6 +67,13 @@
     }
   }
 
+  public ListSubject<CodeOwnerConfigFileInfoSubject, CodeOwnerConfigFileInfo>
+      hasCodeOwnerConfigsThat() {
+    return check("codeOwnerConfigs()")
+        .about(elements())
+        .thatCustom(codeOwnersInfo().codeOwnerConfigs, codeOwnerConfigFileInfos());
+  }
+
   public IterableSubject hasDebugLogsThat() {
     return check("debugLogs").that(codeOwnersInfo().debugLogs);
   }
diff --git a/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java b/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
index 0083497..c8e0c7c 100644
--- a/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
+++ b/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
@@ -463,12 +463,9 @@
         codeOwnersPluginConfiguration.getProjectConfig(branchNameKey.project());
     logger.atFine().log("force = %s", force);
     if (!force && codeOwnersConfig.isDisabled(branchNameKey.branch())) {
-      return Optional.of(
-          ValidationResult.create(
-              pluginName,
-              "skipping validation of code owner config files",
-              new CommitValidationMessage(
-                  "code-owners functionality is disabled", ValidationMessage.Type.HINT)));
+      logger.atFine().log(
+          "skipping validation of code owner config files due to code-owners functionality is disabled");
+      return Optional.empty();
     }
 
     try {
diff --git a/java/com/google/gerrit/plugins/codeowners/validation/SkipCodeOwnerConfigValidationPushOption.java b/java/com/google/gerrit/plugins/codeowners/validation/SkipCodeOwnerConfigValidationPushOption.java
index 4417295..2443ef5 100644
--- a/java/com/google/gerrit/plugins/codeowners/validation/SkipCodeOwnerConfigValidationPushOption.java
+++ b/java/com/google/gerrit/plugins/codeowners/validation/SkipCodeOwnerConfigValidationPushOption.java
@@ -106,7 +106,8 @@
     } catch (PermissionBackendException e) {
       throw newInternalServerError(
           String.format(
-              "Failed to check %s capability", SkipCodeOwnerConfigValidationCapability.ID),
+              "Failed to check %s~%s capability",
+              pluginName, SkipCodeOwnerConfigValidationCapability.ID),
           e);
     }
   }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/AbstractGetCodeOwnersForPathIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/AbstractGetCodeOwnersForPathIT.java
index 74c9eb2..2568906 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/AbstractGetCodeOwnersForPathIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/AbstractGetCodeOwnersForPathIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerConfigFileInfoSubject.assertThat;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerInfoSubject.hasAccountId;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerInfoSubject.hasAccountName;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnersInfoSubject.assertThat;
@@ -25,6 +26,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
@@ -44,13 +46,16 @@
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.TestCodeOwnerConfigCreation;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.TestPathExpressions;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigFileInfo;
 import com.google.gerrit.plugins.codeowners.api.CodeOwners;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnersInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportMode;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigReference;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSet;
+import com.google.gerrit.plugins.codeowners.backend.config.BackendConfig;
 import com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwnerCapability;
 import com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnersForPathInBranch;
 import com.google.inject.Inject;
@@ -77,11 +82,14 @@
   @Inject private GroupOperations groupOperations;
   @Inject private ProjectOperations projectOperations;
 
+  private CodeOwnerBackend backend;
+
   protected TestPathExpressions testPathExpressions;
 
   @Before
   public void setup() throws Exception {
     testPathExpressions = plugin.getSysInjector().getInstance(TestPathExpressions.class);
+    backend = plugin.getSysInjector().getInstance(BackendConfig.class).getDefaultBackend();
   }
 
   /** Must return the {@link CodeOwners} API against which the tests should be run. */
@@ -98,7 +106,10 @@
 
   @Test
   public void getCodeOwnersWhenNoCodeOwnerConfigsExist() throws Exception {
-    assertThat(queryCodeOwners("/foo/bar/baz.md")).hasCodeOwnersThat().isEmpty();
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().isEmpty();
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isNull();
+    assertThat(codeOwnersInfo).hasCodeOwnerConfigsThat().isEmpty();
   }
 
   @Test
@@ -115,6 +126,7 @@
     CodeOwnersInfo codeOwnersInfo = queryCodeOwners("/foo/bar/baz.md");
     assertThat(codeOwnersInfo).hasCodeOwnersThat().isEmpty();
     assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isNull();
+    assertThat(codeOwnersInfo).hasCodeOwnerConfigsThat().isEmpty();
   }
 
   @Test
@@ -136,29 +148,32 @@
   private void testGetCodeOwners(boolean useAbsolutePath) throws Exception {
     TestAccount user2 = accountCreator.user2();
 
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/")
-        .addCodeOwnerEmail(admin.email())
-        .create();
+    CodeOwnerConfig.Key codeOwnerConfigKey1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(admin.email())
+            .create();
 
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    CodeOwnerConfig.Key codeOwnerConfigKey2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/")
+            .addCodeOwnerEmail(user.email())
+            .create();
 
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/bar/")
-        .addCodeOwnerEmail(user2.email())
-        .create();
+    CodeOwnerConfig.Key codeOwnerConfigKey3 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/bar/")
+            .addCodeOwnerEmail(user2.email())
+            .create();
 
     CodeOwnersInfo codeOwnersInfo =
         queryCodeOwners(useAbsolutePath ? "/foo/bar/baz.md" : "foo/bar/baz.md");
@@ -172,6 +187,27 @@
         .comparingElementsUsing(hasAccountName())
         .containsExactly(null, null, null);
     assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isNull();
+
+    assertThat(codeOwnersInfo.codeOwnerConfigs).hasSize(3);
+    CodeOwnerConfigFileInfo codeOwnerConfigFileInfo3 = codeOwnersInfo.codeOwnerConfigs.get(0);
+    CodeOwnerConfigFileInfo codeOwnerConfigFileInfo2 = codeOwnersInfo.codeOwnerConfigs.get(1);
+    CodeOwnerConfigFileInfo codeOwnerConfigFileInfo1 = codeOwnersInfo.codeOwnerConfigs.get(2);
+    assertThat(codeOwnerConfigFileInfo3)
+        .assertKey(backend, codeOwnerConfigKey3)
+        .assertNoImports()
+        .assertNoImportMode()
+        .assertNoUnresolvedErrorMessage();
+    assertThat(codeOwnerConfigFileInfo2)
+        .assertKey(backend, codeOwnerConfigKey2)
+        .assertNoImports()
+        .assertNoImportMode()
+        .assertNoUnresolvedErrorMessage();
+    assertThat(codeOwnerConfigFileInfo1)
+        .assertKey(backend, codeOwnerConfigKey1)
+        .assertNoImports()
+        .assertNoImportMode()
+        .assertNoUnresolvedErrorMessage();
+
     assertThat(codeOwnersInfo).hasDebugLogsThat().isNull();
   }
 
@@ -184,24 +220,26 @@
 
     // 1. code owner config that makes "user2" a code owner, inheriting code owners from parent code
     // owner configs is enabled by default
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/bar/")
-        .addCodeOwnerEmail(user2.email())
-        .create();
+    CodeOwnerConfig.Key codeOwnerConfigKey1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/bar/")
+            .addCodeOwnerEmail(user2.email())
+            .create();
 
     // 2. code owner config that makes "user" a code owner, code owners from parent code owner
     // configs are ignored
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/")
-        .ignoreParentCodeOwners()
-        .addCodeOwnerEmail(user.email())
-        .create();
+    CodeOwnerConfig.Key codeOwnerConfigKey2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/")
+            .ignoreParentCodeOwners()
+            .addCodeOwnerEmail(user.email())
+            .create();
 
     // 3. code owner config that makes "admin" a code owner and assigns code ownership to all users,
     // but for this test this code owner config is ignored, since the 2. code owner config ignores
@@ -227,36 +265,71 @@
         .containsExactly(user2.id(), user.id())
         .inOrder();
     assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isNull();
+
+    assertThat(codeOwnersInfo.codeOwnerConfigs).hasSize(2);
+    CodeOwnerConfigFileInfo codeOwnerConfigFileInfo1 = codeOwnersInfo.codeOwnerConfigs.get(0);
+    CodeOwnerConfigFileInfo codeOwnerConfigFileInfo2 = codeOwnersInfo.codeOwnerConfigs.get(1);
+    assertThat(codeOwnerConfigFileInfo1)
+        .assertKey(backend, codeOwnerConfigKey1)
+        .assertNoImports()
+        .assertNoImportMode()
+        .assertNoUnresolvedErrorMessage();
+    assertThat(codeOwnerConfigFileInfo2)
+        .assertKey(backend, codeOwnerConfigKey2)
+        .assertNoImports()
+        .assertNoImportMode()
+        .assertNoUnresolvedErrorMessage();
   }
 
   @Test
   public void getPerFileCodeOwners() throws Exception {
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/bar/")
-        .addCodeOwnerSet(
-            CodeOwnerSet.builder()
-                .addPathExpression(testPathExpressions.matchFileType("txt"))
-                .addCodeOwnerEmail(admin.email())
-                .build())
-        .addCodeOwnerSet(
-            CodeOwnerSet.builder()
-                .addPathExpression(testPathExpressions.matchFileType("md"))
-                .addCodeOwnerEmail(user.email())
-                .build())
-        .create();
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/bar/")
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addPathExpression(testPathExpressions.matchFileType("txt"))
+                    .addCodeOwnerEmail(admin.email())
+                    .build())
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addPathExpression(testPathExpressions.matchFileType("md"))
+                    .addCodeOwnerEmail(user.email())
+                    .build())
+            .create();
 
-    assertThat(queryCodeOwners("/foo/bar/config.txt"))
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("/foo/bar/config.txt");
+    assertThat(codeOwnersInfo)
         .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id());
-    assertThat(queryCodeOwners("/foo/bar/baz.md"))
+    assertThat(Iterables.getOnlyElement(codeOwnersInfo.codeOwnerConfigs))
+        .assertKey(backend, codeOwnerConfigKey)
+        .assertNoImports()
+        .assertNoImportMode()
+        .assertNoUnresolvedErrorMessage();
+
+    codeOwnersInfo = queryCodeOwners("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
         .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user.id());
-    assertThat(queryCodeOwners("/foo/bar/main.config")).hasCodeOwnersThat().isEmpty();
+    assertThat(Iterables.getOnlyElement(codeOwnersInfo.codeOwnerConfigs))
+        .assertKey(backend, codeOwnerConfigKey)
+        .assertNoImports()
+        .assertNoImportMode()
+        .assertNoUnresolvedErrorMessage();
+
+    codeOwnersInfo = queryCodeOwners("/foo/bar/main.config");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().isEmpty();
+    assertThat(Iterables.getOnlyElement(codeOwnersInfo.codeOwnerConfigs))
+        .assertKey(backend, codeOwnerConfigKey)
+        .assertNoImports()
+        .assertNoImportMode()
+        .assertNoUnresolvedErrorMessage();
   }
 
   @Test
@@ -314,8 +387,9 @@
   }
 
   @Test
-  public void cannotGetCodeOwnersWithSecondaryEmailsWithoutModifyAccountCapability()
-      throws Exception {
+  public void
+      cannotGetCodeOwnersWithSecondaryEmailsWithoutViewSecondaryEmailAndWithoutModifyAccountCapability()
+          throws Exception {
     // create a code owner config
     codeOwnerConfigOperations
         .newCodeOwnerConfig()
@@ -339,7 +413,7 @@
                 queryCodeOwners(
                     getCodeOwnersApi().query().withOptions(ListAccountsOption.ALL_EMAILS),
                     "/foo/bar/baz.md"));
-    assertThat(exception).hasMessageThat().isEqualTo("modify account not permitted");
+    assertThat(exception).hasMessageThat().isEqualTo("view secondary emails not permitted");
   }
 
   @Test
@@ -548,22 +622,24 @@
     TestAccount user2 = accountCreator.user2();
 
     // create some code owner configs
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/")
-        .addCodeOwnerEmail(admin.email())
-        .create();
+    CodeOwnerConfig.Key codeOwnerConfigKey1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(admin.email())
+            .create();
 
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/bar/")
-        .addCodeOwnerEmail(user.email())
-        .addCodeOwnerEmail(user2.email())
-        .create();
+    CodeOwnerConfig.Key codeOwnerConfigKey2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/bar/")
+            .addCodeOwnerEmail(user.email())
+            .addCodeOwnerEmail(user2.email())
+            .create();
 
     // get code owners with different limits
     CodeOwnersInfo codeOwnersInfo =
@@ -576,18 +652,51 @@
         .element(0)
         .hasAccountIdThat()
         .isAnyOf(user.id(), user2.id());
+    assertThat(codeOwnersInfo.codeOwnerConfigs).hasSize(2);
+    assertThat(codeOwnersInfo.codeOwnerConfigs.get(0))
+        .assertKey(backend, codeOwnerConfigKey2)
+        .assertNoImports()
+        .assertNoImportMode()
+        .assertNoUnresolvedErrorMessage();
+    assertThat(codeOwnersInfo.codeOwnerConfigs.get(1))
+        .assertKey(backend, codeOwnerConfigKey1)
+        .assertNoImports()
+        .assertNoImportMode()
+        .assertNoUnresolvedErrorMessage();
 
     codeOwnersInfo = queryCodeOwners(getCodeOwnersApi().query().withLimit(2), "/foo/bar/baz.md");
     assertThat(codeOwnersInfo)
         .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user.id(), user2.id());
+    assertThat(codeOwnersInfo.codeOwnerConfigs).hasSize(2);
+    assertThat(codeOwnersInfo.codeOwnerConfigs.get(0))
+        .assertKey(backend, codeOwnerConfigKey2)
+        .assertNoImports()
+        .assertNoImportMode()
+        .assertNoUnresolvedErrorMessage();
+    assertThat(codeOwnersInfo.codeOwnerConfigs.get(1))
+        .assertKey(backend, codeOwnerConfigKey1)
+        .assertNoImports()
+        .assertNoImportMode()
+        .assertNoUnresolvedErrorMessage();
 
     codeOwnersInfo = getCodeOwnersApi().query().withLimit(3).get("/foo/bar/baz.md");
     assertThat(codeOwnersInfo)
         .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id(), user.id(), user2.id());
+    assertThat(codeOwnersInfo.codeOwnerConfigs).hasSize(2);
+    assertThat(codeOwnersInfo.codeOwnerConfigs.get(0))
+        .assertKey(backend, codeOwnerConfigKey2)
+        .assertNoImports()
+        .assertNoImportMode()
+        .assertNoUnresolvedErrorMessage();
+    assertThat(codeOwnersInfo.codeOwnerConfigs.get(1))
+        .assertKey(backend, codeOwnerConfigKey1)
+        .assertNoImports()
+        .assertNoImportMode()
+        .assertNoUnresolvedErrorMessage();
   }
 
   @Test
@@ -632,10 +741,12 @@
   public void getGlobalCodeOwners() throws Exception {
     TestAccount globalOwner =
         accountCreator.create("global_owner", "global.owner@example.com", "Global Owner", null);
-    assertThat(queryCodeOwners("/foo/bar/baz.md"))
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
         .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(globalOwner.id());
+    assertThat(codeOwnersInfo).hasCodeOwnerConfigsThat().isEmpty();
   }
 
   @Test
@@ -767,18 +878,25 @@
   @Test
   public void getDefaultCodeOwners() throws Exception {
     // Create default code owner config file in refs/meta/config.
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch(RefNames.REFS_CONFIG)
-        .folderPath("/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch(RefNames.REFS_CONFIG)
+            .folderPath("/")
+            .addCodeOwnerEmail(user.email())
+            .create();
 
-    assertThat(queryCodeOwners("/foo/bar/baz.md"))
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
         .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user.id());
+    assertThat(Iterables.getOnlyElement(codeOwnersInfo.codeOwnerConfigs))
+        .assertKey(backend, codeOwnerConfigKey)
+        .assertNoImports()
+        .assertNoImportMode()
+        .assertNoUnresolvedErrorMessage();
   }
 
   @Test
@@ -1460,6 +1578,22 @@
         .element(1)
         .hasAccountIdThat()
         .isAnyOf(user.id(), user2.id());
+
+    codeOwnersInfo =
+        queryCodeOwners(
+            getCodeOwnersApi()
+                .query()
+                .withHighestScoreOnly(/* highestScoreOnly= */ true)
+                .withLimit(1),
+            "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().hasSize(1);
+    // the first 2 code owners have the same scoring, so their order is random and we don't know
+    // which of them is returned
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(0)
+        .hasAccountIdThat()
+        .isAnyOf(user.id(), user2.id());
   }
 
   @Test
@@ -1643,4 +1777,196 @@
     }
     assertThat(foundDifferentOrder).isTrue();
   }
+
+  @Test
+  public void getCodeOwnersWithUnresolvedImport() throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    Project.NameKey nonExistingProject = Project.nameKey("non-existing");
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        CodeOwnerConfig.Key.create(nonExistingProject, "master", "/", "OWNERS");
+    CodeOwnerConfigReference nonResolvableCodeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
+
+    CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/bar/")
+            .addImport(nonResolvableCodeOwnerConfigReference)
+            .addCodeOwnerEmail(admin.email())
+            .addCodeOwnerEmail(user.email())
+            .create();
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners(getCodeOwnersApi().query(), path);
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id(), admin.id());
+    assertThat(Iterables.getOnlyElement(codeOwnersInfo.codeOwnerConfigs))
+        .assertKey(backend, keyOfImportingCodeOwnerConfig)
+        .assertNoResolvedImports()
+        .assertUnresolvedImport(
+            backend,
+            keyOfImportedCodeOwnerConfig,
+            nonResolvableCodeOwnerConfigReference.importMode(),
+            String.format("project %s not found", nonExistingProject));
+  }
+
+  @Test
+  public void getCodeOwnersWithResolvedImport() throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/baz/")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
+
+    CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/bar/")
+            .addImport(codeOwnerConfigReference)
+            .addCodeOwnerEmail(user.email())
+            .create();
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners(getCodeOwnersApi().query(), path);
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id(), admin.id());
+    assertThat(Iterables.getOnlyElement(codeOwnersInfo.codeOwnerConfigs))
+        .assertKey(backend, keyOfImportingCodeOwnerConfig)
+        .assertNoUnresolvedImports()
+        .assertResolvedImport(
+            backend, keyOfImportedCodeOwnerConfig, codeOwnerConfigReference.importMode());
+  }
+
+  @Test
+  public void getCodeOwnersWithNestedImport() throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    TestAccount user2 = accountCreator.user2();
+
+    Project.NameKey nonExistingProject = Project.nameKey("non-existing");
+    CodeOwnerConfig.Key keyOfUnresolvableCodeOwnerConfig =
+        CodeOwnerConfig.Key.create(nonExistingProject, "master", "/", "OWNERS");
+    CodeOwnerConfigReference unresolvableCodeOwnerConfigReference =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+            keyOfUnresolvableCodeOwnerConfig);
+
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .addImport(unresolvableCodeOwnerConfigReference)
+            .addCodeOwnerEmail(admin.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference2 =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig2);
+
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/baz/")
+            .addImport(codeOwnerConfigReference2)
+            .addCodeOwnerEmail(user2.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference1 =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig1);
+
+    CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/bar/")
+            .addImport(codeOwnerConfigReference1)
+            .addCodeOwnerEmail(user.email())
+            .create();
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners(getCodeOwnersApi().query(), path);
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id(), user2.id(), admin.id());
+
+    CodeOwnerConfigFileInfo codeOwnerConfigFileInfo =
+        Iterables.getOnlyElement(codeOwnersInfo.codeOwnerConfigs);
+    assertThat(codeOwnerConfigFileInfo)
+        .assertKey(backend, keyOfImportingCodeOwnerConfig)
+        .assertNoImportMode()
+        .assertNoUnresolvedErrorMessage()
+        .assertNoUnresolvedImports()
+        .assertResolvedImport(
+            backend, keyOfImportedCodeOwnerConfig1, codeOwnerConfigReference1.importMode());
+
+    codeOwnerConfigFileInfo = Iterables.getOnlyElement(codeOwnerConfigFileInfo.imports);
+    assertThat(codeOwnerConfigFileInfo)
+        .assertKey(backend, keyOfImportedCodeOwnerConfig1)
+        .assertImportMode(codeOwnerConfigReference1.importMode())
+        .assertNoUnresolvedErrorMessage()
+        .assertNoUnresolvedImports()
+        .assertResolvedImport(
+            backend, keyOfImportedCodeOwnerConfig2, codeOwnerConfigReference2.importMode());
+
+    codeOwnerConfigFileInfo = Iterables.getOnlyElement(codeOwnerConfigFileInfo.imports);
+    assertThat(codeOwnerConfigFileInfo)
+        .assertKey(backend, keyOfImportedCodeOwnerConfig2)
+        .assertImportMode(codeOwnerConfigReference2.importMode())
+        .assertNoUnresolvedErrorMessage()
+        .assertNoResolvedImports()
+        .assertUnresolvedImport(
+            backend,
+            keyOfUnresolvableCodeOwnerConfig,
+            unresolvableCodeOwnerConfigReference.importMode(),
+            String.format("project %s not found", nonExistingProject));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
+  public void returnedOwnersFileContainsFileExtension() throws Exception {
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .fileName(getCodeOwnerConfigFileName() + ".foo")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id());
+
+    assertThat(codeOwnersInfo.codeOwnerConfigs).hasSize(1);
+    CodeOwnerConfigFileInfo codeOwnerConfigFileInfo = codeOwnersInfo.codeOwnerConfigs.get(0);
+    assertThat(codeOwnerConfigFileInfo)
+        .assertKey(backend, codeOwnerConfigKey)
+        .assertNoImports()
+        .assertNoImportMode()
+        .assertNoUnresolvedErrorMessage();
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerIT.java
index 83eb13f..8c5ce92 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerIT.java
@@ -22,6 +22,7 @@
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerCheckInfoSubject.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.RestResponse;
@@ -58,7 +59,7 @@
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -100,7 +101,7 @@
   @Test
   public void checkCodeOwnerForSymbolicRefPointingToAnUnbornBranch() throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
-      repo.updateRef(Constants.HEAD, true).link("refs/heads/non-existing");
+      testRefAction(() -> repo.updateRef(Constants.HEAD, true).link("refs/heads/non-existing"));
     }
     RestResponse response =
         adminRestSession.get(
@@ -576,7 +577,8 @@
     assertThat(checkCodeOwnerInfo)
         .hasDebugLogsThatContainAllOf(
             String.format(
-                "found the all users wildcard ('%s') as a code owner in %s which makes %s a code owner",
+                "found the all users wildcard ('%s') as a code owner in %s which makes %s a code"
+                    + " owner",
                 CodeOwnerResolver.ALL_USERS_WILDCARD,
                 getCodeOwnerConfigFilePath(ROOT_PATH),
                 CodeOwnerResolver.ALL_USERS_WILDCARD));
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java
index 51b52da..c195401 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java
@@ -449,10 +449,7 @@
                 .codeOwnerConfig(createCodeOwnerConfigKey("/"))
                 .getJGitFilePath(),
             "INVALID");
-    assertOkWithHints(
-        r,
-        "skipping validation of code owner config files",
-        "code-owners functionality is disabled");
+    assertOkWithoutMessages(r);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerSubmitRuleIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerSubmitRuleIT.java
index f3e7d1b..2d2c8c7 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerSubmitRuleIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerSubmitRuleIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.plugins.codeowners.acceptance.api;
 
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
@@ -716,7 +717,7 @@
     try (Repository repo = repoManager.openRepository(project)) {
       RefUpdate ru = repo.updateRef(RefNames.refsCacheAutomerge(mergeCommit.name()));
       ru.setForceUpdate(true);
-      assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+      assertThat(testRefAction(() -> ru.delete())).isEqualTo(RefUpdate.Result.FORCED);
       assertThat(repo.exactRef(RefNames.refsCacheAutomerge(mergeCommit.name()))).isNull();
     }
   }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnAddReviewerIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnAddReviewerIT.java
index 70fb164..e3b51b4 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnAddReviewerIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnAddReviewerIT.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.entities.BranchNameKey;
@@ -45,6 +46,9 @@
  * using the {@link GerritConfig} annotation).
  */
 public class CodeOwnersOnAddReviewerIT extends AbstractCodeOwnersIT {
+  private static String TEST_PATH = "foo/bar.baz";
+  private static String TEST_PATH_ESCAPED = "`foo/bar.baz`";
+
   @Test
   @GerritConfig(name = "plugin.code-owners.disabled", value = "true")
   public void noChangeMessageAddedIfCodeOwnersFuctionalityIsDisabled() throws Exception {
@@ -56,7 +60,7 @@
         .addCodeOwnerEmail(user.email())
         .create();
 
-    String changeId = createChange("Test Change", "foo/bar.baz", "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     gApi.changes().id(changeId).addReviewer(user.email());
 
@@ -74,7 +78,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String changeId = createChange("Test Change", "foo/bar.baz", "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     gApi.changes().id(changeId).addReviewer(user.email());
 
@@ -86,7 +90,7 @@
   public void noChangeMessageAddedIfInvalidCodeOwnerConfigFilesExist() throws Exception {
     createNonParseableCodeOwnerConfig(getCodeOwnerConfigFileName());
 
-    String changeId = createChange("Test Change", "foo/bar.baz", "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     gApi.changes().id(changeId).addReviewer(user.email());
 
@@ -104,8 +108,7 @@
         .addCodeOwnerEmail(user.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     gApi.changes().id(changeId).addReviewer(user.email());
 
@@ -114,7 +117,7 @@
         .isEqualTo(
             String.format(
                 "%s, who was added as reviewer owns the following files:\n* %s\n",
-                AccountTemplateUtil.getAccountTemplate(user.id()), path));
+                AccountTemplateUtil.getAccountTemplate(user.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -127,15 +130,17 @@
         .addCodeOwnerEmail(user.email())
         .create();
 
-    String path1 = "foo/bar.baz";
-    String path2 = "foo/baz.bar";
+    String testPath1 = "foo/bar.baz";
+    String testPath1Escaped = "`foo/bar.baz`";
+    String testPath2 = "foo/baz.bar";
+    String testPath2Escaped = "`foo/baz.bar`";
     String changeId =
         createChange(
                 "Test Change",
                 ImmutableMap.of(
-                    path1,
+                    testPath1,
                     "file content",
-                    path2,
+                    testPath2,
                     "file content",
                     "bar/foo.baz",
                     "file content",
@@ -150,7 +155,9 @@
         .isEqualTo(
             String.format(
                 "%s, who was added as reviewer owns the following files:\n* %s\n* %s\n",
-                AccountTemplateUtil.getAccountTemplate(user.id()), path1, path2));
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                testPath1Escaped,
+                testPath2Escaped));
   }
 
   @Test
@@ -163,8 +170,7 @@
         .addCodeOwnerEmail(user.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     gApi.changes().id(changeId).addReviewer(user.email());
 
@@ -188,21 +194,25 @@
         .addCodeOwnerEmail(user.email())
         .create();
 
-    String path1 = "foo/bar.baz";
-    String path2 = "foo/baz.bar";
-    String path3 = "bar/foo.baz";
-    String path4 = "bar/baz.foo";
+    String testPath1 = "foo/bar.baz";
+    String testPath1Escaped = "`foo/bar.baz`";
+    String testPath2 = "foo/baz.bar";
+    String testPath2Escaped = "`foo/baz.bar`";
+    String testPath3 = "bar/foo.baz";
+    String testPath3Escaped = "`bar/foo.baz`";
+    String testPath4 = "bar/baz.foo";
+    String testPath4Escaped = "`bar/baz.foo`";
     String changeId =
         createChange(
                 "Test Change",
                 ImmutableMap.of(
-                    path1,
+                    testPath1,
                     "file content",
-                    path2,
+                    testPath2,
                     "file content",
-                    path3,
+                    testPath3,
                     "file content",
-                    path4,
+                    testPath4,
                     "file content"))
             .getChangeId();
 
@@ -217,7 +227,11 @@
                     + "* %s\n"
                     + "* %s\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(user.id()), path4, path3, path1, path2));
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                testPath4Escaped,
+                testPath3Escaped,
+                testPath1Escaped,
+                testPath2Escaped));
   }
 
   @Test
@@ -231,24 +245,27 @@
         .addCodeOwnerEmail(user.email())
         .create();
 
-    String path1 = "foo/bar.baz";
-    String path2 = "foo/baz.bar";
-    String path3 = "bar/foo.baz";
-    String path4 = "bar/baz.foo";
-    String path5 = "baz/foo.bar";
+    String testPath1 = "foo/bar.baz";
+    String testPath2 = "foo/baz.bar";
+    String testPath3 = "bar/foo.baz";
+    String testPath3Escaped = "`bar/foo.baz`";
+    String testPath4 = "bar/baz.foo";
+    String testPath4Escaped = "`bar/baz.foo`";
+    String testPath5 = "baz/foo.bar";
+    String testPath5Escaped = "`baz/foo.bar`";
     String changeId =
         createChange(
                 "Test Change",
                 ImmutableMap.of(
-                    path1,
+                    testPath1,
                     "file content",
-                    path2,
+                    testPath2,
                     "file content",
-                    path3,
+                    testPath3,
                     "file content",
-                    path4,
+                    testPath4,
                     "file content",
-                    path5,
+                    testPath5,
                     "file content"))
             .getChangeId();
 
@@ -263,7 +280,10 @@
                     + "* %s\n"
                     + "* %s\n"
                     + "(more files)\n",
-                AccountTemplateUtil.getAccountTemplate(user.id()), path4, path3, path5));
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                testPath4Escaped,
+                testPath3Escaped,
+                testPath5Escaped));
   }
 
   @Test
@@ -334,8 +354,7 @@
         .addCodeOwnerEmail(user.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     // Add reviewer via PostReview.
     gApi.changes().id(changeId).current().review(ReviewInput.create().reviewer(user.email()));
@@ -345,7 +364,7 @@
         .isEqualTo(
             String.format(
                 "%s, who was added as reviewer owns the following files:\n* %s\n",
-                AccountTemplateUtil.getAccountTemplate(user.id()), path));
+                AccountTemplateUtil.getAccountTemplate(user.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -361,8 +380,7 @@
         .addCodeOwnerEmail(user2.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     // Add code owners 'user' and 'user2' as reviewers.
     gApi.changes()
@@ -379,9 +397,9 @@
                 "%s, who was added as reviewer owns the following files:\n* %s\n\n"
                     + "%s, who was added as reviewer owns the following files:\n* %s\n",
                 AccountTemplateUtil.getAccountTemplate(user.id()),
-                path,
+                TEST_PATH_ESCAPED,
                 AccountTemplateUtil.getAccountTemplate(user2.id()),
-                path));
+                TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -395,8 +413,7 @@
         .addCodeOwnerEmail(user.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     // 'admin' grants a code owner approval (Code-Review+1) and adds 'user' as reviewer.
     gApi.changes().id(changeId).current().review(ReviewInput.recommend().reviewer(user.email()));
@@ -412,12 +429,42 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
     assertThat(Iterables.getLast(messages).message)
         .isEqualTo(
             String.format(
                 "%s, who was added as reviewer owns the following files:\n* %s\n",
-                AccountTemplateUtil.getAccountTemplate(user.id()), path));
+                AccountTemplateUtil.getAccountTemplate(user.id()), TEST_PATH_ESCAPED));
+  }
+
+  @Test
+  public void markdownCharactersInPathsAreEscaped() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    testMarkdownCharactersInPathsAreEscaped('`', user);
+  }
+
+  private void testMarkdownCharactersInPathsAreEscaped(
+      char markdownCharacter, TestAccount codeOwner) throws Exception {
+    String testPath = markdownCharacter + "foo" + markdownCharacter + ".bar";
+    String testPathEscaped = "`\\" + markdownCharacter + "foo\\" + markdownCharacter + ".bar`";
+
+    String changeId = createChange("Test Change", testPath, "file content").getChangeId();
+
+    gApi.changes().id(changeId).addReviewer(codeOwner.email());
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "%s, who was added as reviewer owns the following files:\n* %s\n",
+                AccountTemplateUtil.getAccountTemplate(codeOwner.id()), testPathEscaped));
   }
 
   @Test
@@ -431,8 +478,7 @@
         .addCodeOwnerEmail(user.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     gApi.changes().id(changeId).addReviewer(user.email());
 
@@ -440,7 +486,7 @@
         changeId,
         String.format(
             "%s, who was added as reviewer owns the following files:\n* %s\n",
-            AccountTemplateUtil.getAccountTemplate(user.id()), path));
+            AccountTemplateUtil.getAccountTemplate(user.id()), TEST_PATH_ESCAPED));
   }
 
   private void assertAsyncChangeMessage(String changeId, String expectedChangeMessage)
@@ -453,6 +499,7 @@
         });
   }
 
+  @CanIgnoreReturnValue
   private <T> T assertAsync(Callable<T> assertion) throws Exception {
     return RetryerBuilder.<T>newBuilder()
         .retryIfException(t -> true)
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 b2a6630..b0caf0a 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
@@ -631,6 +631,27 @@
   }
 
   @Test
+  public void configureEnableAsyncMessageOnCodeOwnerApproval() throws Exception {
+    fetchRefsMetaConfig();
+
+    Config cfg = new Config();
+    cfg.setBoolean(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        GeneralConfig.KEY_ENABLE_ASYNC_MESSAGE_ON_CODE_OWNER_APPROVAL,
+        false);
+    setCodeOwnersConfig(cfg);
+
+    PushResult r = pushRefsMetaConfig();
+    assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .enableAsyncMessageOnCodeOwnerApproval())
+        .isFalse();
+  }
+
+  @Test
   public void cannotSetInvalidMaxPathsInChangeMessages() throws Exception {
     fetchRefsMetaConfig();
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerConfigForPathInBranchIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerConfigForPathInBranchIT.java
index 5ddd85f..843b656 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerConfigForPathInBranchIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerConfigForPathInBranchIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.plugins.codeowners.acceptance.api;
 
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerConfigInfoSubject.assertThatOptional;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -54,7 +55,7 @@
   @GerritConfig(name = "plugin.code-owners.enableExperimentalRestEndpoints", value = "true")
   public void getCodeOwnerConfigFromSymbolicRefPointingToAnUnbornBranch() throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
-      repo.updateRef(Constants.HEAD, true).link("refs/heads/non-existing");
+      testRefAction(() -> repo.updateRef(Constants.HEAD, true).link("refs/heads/non-existing"));
     }
     RestResponse response =
         adminRestSession.get(
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInBranchIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInBranchIT.java
index a78d420..34d2271 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInBranchIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInBranchIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.plugins.codeowners.acceptance.api;
 
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerInfoSubject.hasAccountId;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnersInfoSubject.assertThat;
@@ -73,7 +74,7 @@
   @Test
   public void getCodeOwnersFromSymbolicRefPointingToAnUnbornBranch() throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
-      repo.updateRef(Constants.HEAD, true).link("refs/heads/non-existing");
+      testRefAction(() -> repo.updateRef(Constants.HEAD, true).link("refs/heads/non-existing"));
     }
     RestResponse response =
         adminRestSession.get(
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerApprovalIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerApprovalIT.java
index d3fe19a..37739b1 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerApprovalIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerApprovalIT.java
@@ -16,11 +16,16 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
@@ -34,13 +39,26 @@
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
+import java.time.Duration;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
+import java.util.concurrent.Callable;
 import org.junit.Test;
 
-/** Acceptance test for {@code com.google.gerrit.plugins.codeowners.backend.OnCodeOwnerApproval}. */
+/**
+ * Acceptance test for {@code com.google.gerrit.plugins.codeowners.backend.OnCodeOwnerApproval}.
+ *
+ * <p>For tests the change message that is posted when a code owner approval is applied, is added
+ * synchronously by default (see {@link AbstractCodeOwnersIT #defaultConfig()}). Tests that want to
+ * verify the asynchronous posting of this change message need to set {@code
+ * plugin.code-owners.enableAsyncMessageOnCodeOwnerApproval=true} in {@code gerrit.config}
+ * explicitly (by using the {@link GerritConfig} annotation).
+ */
 public class OnCodeOwnerApprovalIT extends AbstractCodeOwnersIT {
+  private static String TEST_PATH = "foo/bar.baz";
+  private static String TEST_PATH_ESCAPED = "`foo/bar.baz`";
+
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ProjectOperations projectOperations;
 
@@ -55,8 +73,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -68,8 +85,7 @@
   public void changeMessageNotExtendedIfInvalidCodeOwnerConfigFilesExist() throws Exception {
     createNonParseableCodeOwnerConfig(getCodeOwnerConfigFileName());
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -87,8 +103,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -100,7 +115,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -113,8 +128,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -134,7 +148,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -163,8 +177,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -176,7 +189,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
 
     // Apply the Code-Review+1 approval again and add an unrelated vote (Code-Review+1 is ignored).
     ReviewInput reviewInput = ReviewInput.recommend();
@@ -213,8 +226,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -226,13 +238,13 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
 
     // Apply the Code-Review+1 approval again and add a comment (Code-Review +1 is ignored)
     ReviewInput.CommentInput commentInput = new ReviewInput.CommentInput();
     commentInput.line = 1;
     commentInput.message = "some comment";
-    commentInput.path = path;
+    commentInput.path = TEST_PATH;
     ReviewInput reviewInput = ReviewInput.recommend();
     reviewInput.comments = new HashMap<>();
     reviewInput.comments.put(commentInput.path, Lists.newArrayList(commentInput));
@@ -254,8 +266,7 @@
         .addCodeOwnerEmail(user.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -267,7 +278,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
 
     // Apply the Code-Review+1 by another code owner
     requestScopeOperations.setApiUser(user.id());
@@ -281,7 +292,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(user.id()), path));
+                AccountTemplateUtil.getAccountTemplate(user.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -294,8 +305,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -310,7 +320,7 @@
                     + "By voting Code-Review+2 the following files are still code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -326,8 +336,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     approve(changeId);
 
@@ -342,7 +351,7 @@
                     + "By voting Code-Review+1 the following files are still explicitly code-owner"
                     + " approved by %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -358,8 +367,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -374,7 +382,7 @@
                     + "By voting Code-Review+2 the following files are still explicitly code-owner"
                     + " approved by %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -387,8 +395,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     approve(changeId);
 
@@ -403,7 +410,7 @@
                     + "By voting Code-Review+1 the following files are still code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -416,8 +423,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -432,7 +438,7 @@
                     + "By removing the Code-Review vote the following files are no longer"
                     + " code-owner approved by %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -446,8 +452,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -462,7 +467,7 @@
                     + "By voting Code-Review-1 the following files are no longer code-owner"
                     + " approved by %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -475,15 +480,17 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path1 = "foo/bar.baz";
-    String path2 = "foo/baz.bar";
+    String testPath1 = "foo/bar.baz";
+    String testPath1Escaped = "`foo/bar.baz`";
+    String testPath2 = "foo/baz.bar";
+    String testPath2Escaped = "`foo/baz.bar`";
     String changeId =
         createChange(
                 "Test Change",
                 ImmutableMap.of(
-                    path1,
+                    testPath1,
                     "file content",
-                    path2,
+                    testPath2,
                     "file content",
                     "bar/foo.baz",
                     "file content",
@@ -502,7 +509,9 @@
                     + " %s:\n"
                     + "* %s\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path1, path2));
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                testPath1Escaped,
+                testPath2Escaped));
   }
 
   @Test
@@ -517,6 +526,7 @@
 
     // createChangeWithFileRename creates a change with 2 patch sets
     String oldPath = "foo/bar.baz";
+    String oldPathEscaped = "`foo/bar.baz`";
     String newPath = "bar/baz.bar";
     String changeId = createChangeWithFileRename(oldPath, newPath);
 
@@ -530,7 +540,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), oldPath));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), oldPathEscaped));
   }
 
   @Test
@@ -570,7 +580,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String changeId = createChange("Test Change", "foo/bar.baz", "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -590,8 +600,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -603,7 +612,7 @@
                     + "By voting Code-Review+1 the following files are now explicitly code-owner"
                     + " approved by %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -618,8 +627,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -637,7 +645,7 @@
                     + "\n"
                     + "The listed files are still implicitly approved by %s.\n",
                 AccountTemplateUtil.getAccountTemplate(admin.id()),
-                path,
+                TEST_PATH_ESCAPED,
                 AccountTemplateUtil.getAccountTemplate(admin.id())));
   }
 
@@ -653,8 +661,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -672,7 +679,7 @@
                     + "\n"
                     + "The listed files are still implicitly approved by %s.\n",
                 AccountTemplateUtil.getAccountTemplate(admin.id()),
-                path,
+                TEST_PATH_ESCAPED,
                 AccountTemplateUtil.getAccountTemplate(admin.id())));
   }
 
@@ -688,8 +695,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange(user, "Test Change", path, "file content").getChangeId();
+    String changeId = createChange(user, "Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -701,7 +707,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -717,8 +723,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange(user, "Test Change", path, "file content").getChangeId();
+    String changeId = createChange(user, "Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -733,7 +738,7 @@
                     + "By removing the Code-Review vote the following files are no longer"
                     + " code-owner approved by %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -749,8 +754,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange(user, "Test Change", path, "file content").getChangeId();
+    String changeId = createChange(user, "Test Change", TEST_PATH, "file content").getChangeId();
 
     recommend(changeId);
 
@@ -765,7 +769,7 @@
                     + "By voting Code-Review-1 the following files are no longer code-owner"
                     + " approved by %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -779,21 +783,25 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path1 = "foo/bar.baz";
-    String path2 = "foo/baz.bar";
-    String path3 = "bar/foo.baz";
-    String path4 = "bar/baz.foo";
+    String testPath1 = "foo/bar.baz";
+    String testPath1Escaped = "`foo/bar.baz`";
+    String testPath2 = "foo/baz.bar";
+    String testPath2Escaped = "`foo/baz.bar`";
+    String testPath3 = "bar/foo.baz";
+    String testPath3Escaped = "`bar/foo.baz`";
+    String testPath4 = "bar/baz.foo";
+    String testPath4Escaped = "`bar/baz.foo`";
     String changeId =
         createChange(
                 "Test Change",
                 ImmutableMap.of(
-                    path1,
+                    testPath1,
                     "file content",
-                    path2,
+                    testPath2,
                     "file content",
-                    path3,
+                    testPath3,
                     "file content",
-                    path4,
+                    testPath4,
                     "file content"))
             .getChangeId();
 
@@ -810,7 +818,11 @@
                     + "* %s\n"
                     + "* %s\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path4, path3, path1, path2));
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                testPath4Escaped,
+                testPath3Escaped,
+                testPath1Escaped,
+                testPath2Escaped));
   }
 
   @Test
@@ -824,24 +836,27 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path1 = "foo/bar.baz";
-    String path2 = "foo/baz.bar";
-    String path3 = "bar/foo.baz";
-    String path4 = "bar/baz.foo";
-    String path5 = "baz/foo.bar";
+    String testPath1 = "foo/bar.baz";
+    String testPath2 = "foo/baz.bar";
+    String testPath3 = "bar/foo.baz";
+    String testPath3Escaped = "`bar/foo.baz`";
+    String testPath4 = "bar/baz.foo";
+    String testPath4Escaped = "`bar/baz.foo`";
+    String testPath5 = "baz/foo.bar";
+    String testPath5Escaped = "`baz/foo.bar`";
     String changeId =
         createChange(
                 "Test Change",
                 ImmutableMap.of(
-                    path1,
+                    testPath1,
                     "file content",
-                    path2,
+                    testPath2,
                     "file content",
-                    path3,
+                    testPath3,
                     "file content",
-                    path4,
+                    testPath4,
                     "file content",
-                    path5,
+                    testPath5,
                     "file content"))
             .getChangeId();
 
@@ -858,7 +873,10 @@
                     + "* %s\n"
                     + "* %s\n"
                     + "(more files)\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path4, path3, path5));
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                testPath4Escaped,
+                testPath3Escaped,
+                testPath5Escaped));
   }
 
   @Test
@@ -903,13 +921,12 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     ReviewInput.CommentInput commentInput = new ReviewInput.CommentInput();
     commentInput.line = 1;
     commentInput.message = "some comment";
-    commentInput.path = path;
+    commentInput.path = TEST_PATH;
     ReviewInput reviewInput = ReviewInput.recommend();
     reviewInput.comments = new HashMap<>();
     reviewInput.comments.put(commentInput.path, Lists.newArrayList(commentInput));
@@ -924,7 +941,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -937,7 +954,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String changeId = createChange("Test Change", "foo/bar.baz", "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     // create a second patch set
     amendChange(changeId);
@@ -1069,8 +1086,7 @@
     input.ignoreSelfApproval = true;
     gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     requestScopeOperations.setApiUser(user.id());
     recommend(changeId);
@@ -1083,7 +1099,7 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(user.id()), path));
+                AccountTemplateUtil.getAccountTemplate(user.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -1096,8 +1112,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     // Apply the Code-Review-1.
     gApi.changes().id(changeId).current().review(ReviewInput.dislike());
@@ -1120,8 +1135,7 @@
         .addCodeOwnerEmail(user.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     // Do the voting as a different user to trigger an email notification (if the only recipient is
     // also the sender the email is omitted).
@@ -1141,6 +1155,94 @@
                     + "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s <%s>:\n"
                     + "* %s\n",
-                user.fullName(), user.email(), path));
+                user.fullName(), user.email(), TEST_PATH_ESCAPED));
+  }
+
+  @Test
+  public void markdownCharactersInPathsAreEscaped() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+
+    testMarkdownCharactersInPathsAreEscaped('`', user);
+  }
+
+  private void testMarkdownCharactersInPathsAreEscaped(
+      char markdownCharacter, TestAccount codeOwner) throws Exception {
+    String testPath = markdownCharacter + "foo" + markdownCharacter + ".bar";
+    String testPathEscaped = "`\\" + markdownCharacter + "foo\\" + markdownCharacter + ".bar`";
+
+    String changeId = createChange("Test Change", testPath, "file content").getChangeId();
+
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review+1\n\n"
+                    + "By voting Code-Review+1 the following files are now code-owner approved by"
+                    + " %s:\n"
+                    + "* %s\n",
+                AccountTemplateUtil.getAccountTemplate(codeOwner.id()), testPathEscaped));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableAsyncMessageOnCodeOwnerApproval", value = "true")
+  public void changeMessageListsNewlyApprovedPaths_async() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
+
+    int numberOfChangeMessages = gApi.changes().id(changeId).get().messages.size();
+
+    recommend(changeId);
+
+    // expect that 2 changes messages are posted, one for applying the approval and one to inform
+    // about the owned paths
+    int expectedNumberOfChangeMessages = numberOfChangeMessages + 2;
+
+    assertAsyncChangeMessage(
+        changeId,
+        String.format(
+            "By voting Code-Review+1 the following files are now code-owner approved by"
+                + " %s:\n"
+                + "* %s\n",
+            AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED),
+        expectedNumberOfChangeMessages);
+  }
+
+  private void assertAsyncChangeMessage(
+      String changeId, String expectedChangeMessage, int expectedNumberOfChangeMessages)
+      throws Exception {
+    assertAsync(
+        () -> {
+          Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+          assertThat(Iterables.getLast(messages).message).isEqualTo(expectedChangeMessage);
+          assertThat(messages).hasSize(expectedNumberOfChangeMessages);
+          return null;
+        });
+  }
+
+  @CanIgnoreReturnValue
+  private <T> T assertAsync(Callable<T> assertion) throws Exception {
+    return RetryerBuilder.<T>newBuilder()
+        .retryIfException(t -> true)
+        .withStopStrategy(
+            StopStrategies.stopAfterDelay(Duration.ofSeconds(3).toMillis(), MILLISECONDS))
+        .build()
+        .call(() -> assertion.call());
   }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerOverrrideIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerOverrrideIT.java
index eaf507e..9997432 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerOverrrideIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerOverrrideIT.java
@@ -42,6 +42,9 @@
 
 /** Acceptance test for {@code com.google.gerrit.plugins.codeowners.backend.OnCodeOwnerOverride}. */
 public class OnCodeOwnerOverrrideIT extends AbstractCodeOwnersIT {
+  private static String TEST_PATH = "foo/bar.baz";
+  private static String TEST_PATH_ESCAPED = "`foo/bar.baz`";
+
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ProjectOperations projectOperations;
 
@@ -289,13 +292,12 @@
       throws Exception {
     createOwnersOverrideLabel();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     ReviewInput.CommentInput commentInput = new ReviewInput.CommentInput();
     commentInput.line = 1;
     commentInput.message = "some comment";
-    commentInput.path = path;
+    commentInput.path = TEST_PATH;
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.labels = new HashMap<>();
     reviewInput.labels.put("Owners-Override", (short) 1);
@@ -401,8 +403,7 @@
 
     createOwnersOverrideLabel();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.labels = new HashMap<>();
@@ -431,7 +432,7 @@
                 "By voting Code-Review+1 the following files are now code-owner approved by"
                     + " %s:\n"
                     + "* %s\n",
-                AccountTemplateUtil.getAccountTemplate(admin.id()), path));
+                AccountTemplateUtil.getAccountTemplate(admin.id()), TEST_PATH_ESCAPED));
   }
 
   @Test
@@ -632,8 +633,7 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    String path = "foo/bar.baz";
-    String changeId = createChange("Test Change", path, "file content").getChangeId();
+    String changeId = createChange("Test Change", TEST_PATH, "file content").getChangeId();
 
     gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", -1));
 
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 463d623..0eb2793 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/RenameEmailIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/RenameEmailIT.java
@@ -15,14 +15,19 @@
 package com.google.gerrit.plugins.codeowners.acceptance.api;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerConfigSubject.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.Iterables;
+import com.google.common.util.concurrent.AtomicLongMap;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -31,6 +36,7 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -42,7 +48,17 @@
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigFileUpdateScanner;
 import com.google.gerrit.plugins.codeowners.restapi.RenameEmail;
 import com.google.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.Optional;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -58,6 +74,7 @@
   @Inject private AccountOperations accountOperations;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   private CodeOwnerConfigFileUpdateScanner codeOwnerConfigFileUpdateScanner;
 
@@ -288,7 +305,7 @@
     projectOperations
         .project(allProjects)
         .forUpdate()
-        .add(allowCapability(GlobalCapability.MODIFY_ACCOUNT).group(REGISTERED_USERS))
+        .add(allowCapability(GlobalCapability.VIEW_SECONDARY_EMAILS).group(REGISTERED_USERS))
         .update();
 
     String secondaryEmail = "admin-foo@example.com";
@@ -298,8 +315,12 @@
     RenameEmailInput input = new RenameEmailInput();
     input.oldEmail = admin.email();
     input.newEmail = secondaryEmail;
-    RenameEmailResultInfo result = renameEmail(project, "master", input);
-    assertThat(result.commit).isNotNull();
+    RefUpdateCounter refUpdateCounter = new RefUpdateCounter();
+    try (Registration registration = extensionRegistry.newRegistration().add(refUpdateCounter)) {
+      RenameEmailResultInfo result = renameEmail(project, "master", input);
+      assertThat(result.commit).isNotNull();
+    }
+    refUpdateCounter.assertRefUpdateFor(RefUpdateCounter.projectRef(project, "refs/heads/master"));
 
     assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey1).get())
         .hasCodeOwnerSetsThat()
@@ -342,8 +363,12 @@
     RenameEmailInput input = new RenameEmailInput();
     input.oldEmail = admin.email();
     input.newEmail = secondaryEmail;
-    RenameEmailResultInfo result = renameEmail(project, "master", input);
-    assertThat(result.commit).isNotNull();
+    RefUpdateCounter refUpdateCounter = new RefUpdateCounter();
+    try (Registration registration = extensionRegistry.newRegistration().add(refUpdateCounter)) {
+      RenameEmailResultInfo result = renameEmail(project, "master", input);
+      assertThat(result.commit).isNotNull();
+    }
+    refUpdateCounter.assertRefUpdateFor(RefUpdateCounter.projectRef(project, "refs/heads/master"));
 
     assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey1).get())
         .hasCodeOwnerSetsThat()
@@ -668,7 +693,7 @@
   }
 
   @Test
-  public void renameEmail_emailThatContainsEmailToBeReplacesAsSubstringStaysIntact()
+  public void renameEmail_emailThatContainsEmailToBeReplacedAsSubstringStaysIntact()
       throws Exception {
     skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
 
@@ -719,6 +744,122 @@
             secondaryEmail, otherUser1.email(), otherUser2.email(), otherUser3.email());
   }
 
+  @Test
+  public void renameEmailDoesNotTouchCodeOwnerConfigsThatDoNotContainTheEmail() throws Exception {
+    skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
+
+    TestAccount user2 = accountCreator.user2();
+
+    CodeOwnerConfig.Key codeOwnerConfigKey1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(admin.email())
+            .addCodeOwnerEmail(user.email())
+            .create();
+
+    // Create a code owner config that doesn't contain the email to be replaced.
+    CodeOwnerConfig.Key codeOwnerConfigKey2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/")
+            .addCodeOwnerEmail(user2.email())
+            .create();
+
+    // grant all users direct push permissions
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    requestScopeOperations.setApiUser(user.id());
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey1).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail, admin.email());
+
+    // Check that the second code owner config is still intact.
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey2).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(user2.email());
+  }
+
+  @Test
+  public void renameEmailDoesNotTouchNonCodeOwnerConfigFiles() throws Exception {
+    skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
+
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(admin.email())
+            .addCodeOwnerEmail(user.email())
+            .create();
+
+    // Create non code owner config files.
+    String contentFileA = "some content";
+    String contentFileB =
+        String.format(
+            "some content that contains the email %s that is being renamed", user.email());
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo.update(
+          "master",
+          testRepo
+              .commit()
+              .message("Update project.config from test")
+              .parent(projectOperations.project(project).getHead("master"))
+              .add("A", contentFileA)
+              .add("B", contentFileB));
+    }
+
+    // grant all users direct push permissions
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    requestScopeOperations.setApiUser(user.id());
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail, admin.email());
+
+    // Check that the non code owner config files are still intact.
+    assertThat(getFileContent(project, "master", "A")).hasValue(contentFileA);
+    assertThat(getFileContent(project, "master", "B")).hasValue(contentFileB);
+  }
+
   private RenameEmailResultInfo renameEmail(
       Project.NameKey projectName, String branchName, RenameEmailInput input)
       throws RestApiException {
@@ -732,4 +873,62 @@
     // the proto backend doesn't support renaming emails
     assumeThatCodeOwnersBackendIsNotProtoBackend();
   }
+
+  private Optional<String> getFileContent(Project.NameKey project, String branch, String fileName) {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      if (!branch.startsWith(Constants.R_REFS)) {
+        branch = Constants.R_HEADS + branch;
+      }
+      Ref ref = repo.exactRef(branch);
+      if (ref == null) {
+        return Optional.empty();
+      }
+      RevTree tree = rw.parseTree(ref.getObjectId());
+      TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), fileName, tree);
+      if (tw == null) {
+        return Optional.empty();
+      }
+      ObjectLoader loader = rw.getObjectReader().open(tw.getObjectId(0));
+      String fileContent = new String(loader.getCachedBytes(), UTF_8);
+      return Optional.of(fileContent);
+    } catch (Exception e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  private static class RefUpdateCounter implements GitReferenceUpdatedListener {
+    private final AtomicLongMap<String> countsByProjectRefs = AtomicLongMap.create();
+
+    static String projectRef(Project.NameKey project, String ref) {
+      return projectRef(project.get(), ref);
+    }
+
+    static String projectRef(String project, String ref) {
+      return project + ":" + ref;
+    }
+
+    @Override
+    public void onGitReferenceUpdated(Event event) {
+      countsByProjectRefs.incrementAndGet(projectRef(event.getProjectName(), event.getRefName()));
+    }
+
+    void clear() {
+      countsByProjectRefs.clear();
+    }
+
+    void assertRefUpdateFor(String... projectRefs) {
+      Map<String, Long> expectedRefUpdateCounts = new HashMap<>();
+      for (String projectRef : projectRefs) {
+        expectedRefUpdateCounts.put(projectRef, 1L);
+      }
+      assertRefUpdateFor(expectedRefUpdateCounts);
+    }
+
+    void assertRefUpdateFor(Map<String, Long> expectedProjectRefUpdateCounts) {
+      assertThat(countsByProjectRefs.asMap())
+          .containsExactlyEntriesIn(expectedProjectRefUpdateCounts);
+      clear();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/batch/CodeOwnerSubmitRuleBatchIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/batch/CodeOwnerSubmitRuleBatchIT.java
index 17d481c..977af94 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/batch/CodeOwnerSubmitRuleBatchIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/batch/CodeOwnerSubmitRuleBatchIT.java
@@ -19,13 +19,13 @@
 import static com.google.gerrit.plugins.codeowners.testing.LegacySubmitRequirementInfoSubject.assertThatCollection;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
-import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestPlugin;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
 import com.google.gerrit.plugins.codeowners.testing.LegacySubmitRequirementInfoSubject;
 import com.google.inject.Inject;
@@ -42,14 +42,14 @@
 @TestPlugin(
     name = "code-owners",
     sysModule = "com.google.gerrit.plugins.codeowners.acceptance.TestBatchModule")
-public class CodeOwnerSubmitRuleBatchIT extends LightweightPluginDaemonTest {
+public class CodeOwnerSubmitRuleBatchIT extends AbstractCodeOwnersTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   private CodeOwnerConfigOperations codeOwnerConfigOperations;
 
   @Before
-  public void testSetup() throws Exception {
+  public void setUpCodeOwnersPlugin() throws Exception {
     codeOwnerConfigOperations =
         plugin.getSysInjector().getInstance(CodeOwnerConfigOperations.class);
   }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java
index 5c31eed..b6c3a43 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java
@@ -52,7 +52,7 @@
   /** Returns a {@code gerrit.config} that configures all users as fallback code owners. */
   @ConfigSuite.Default
   public static Config defaultConfig() {
-    Config cfg = new Config();
+    Config cfg = AbstractCodeOwnersTest.defaultConfig();
     cfg.setEnum(
         "plugin",
         "code-owners",
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithSelfApprovalsIgnoredTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithSelfApprovalsIgnoredTest.java
index a665ad4..43ab903 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithSelfApprovalsIgnoredTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithSelfApprovalsIgnoredTest.java
@@ -46,7 +46,7 @@
   /** Returns a {@code gerrit.config} that configures all users as fallback code owners. */
   @ConfigSuite.Default
   public static Config defaultConfig() {
-    Config cfg = new Config();
+    Config cfg = AbstractCodeOwnersTest.defaultConfig();
     cfg.setString(
         "plugin", "code-owners", OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL, "Owners-Override+1");
     return cfg;
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithStickyApprovalsTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithStickyApprovalsTest.java
index fc73235..28f83f5 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithStickyApprovalsTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithStickyApprovalsTest.java
@@ -55,7 +55,7 @@
   /** Returns a {@code gerrit.config} that configures all users as fallback code owners. */
   @ConfigSuite.Default
   public static Config defaultConfig() {
-    Config cfg = new Config();
+    Config cfg = AbstractCodeOwnersTest.defaultConfig();
     cfg.setBoolean(
         "plugin", "code-owners", GeneralConfig.KEY_ENABLE_STICKY_APPROVALS, /* value= */ true);
     return cfg;
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigImportTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigImportTest.java
new file mode 100644
index 0000000..b6efbc6
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigImportTest.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import org.junit.Test;
+
+/** Tests for {@link CodeOwnerConfigImport}. */
+public class CodeOwnerConfigImportTest extends AbstractAutoValueTest {
+  @Test
+  public void toStringIncludesAllData_resolvedImport() throws Exception {
+    CodeOwnerConfigImport resolvedImport =
+        CodeOwnerConfigImport.createResolvedImport(
+            CodeOwnerConfig.Key.create(project, "master", "/"),
+            CodeOwnerConfig.Key.create(project, "master", "/bar/"),
+            CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS"));
+    assertThatToStringIncludesAllData(resolvedImport, CodeOwnerConfigImport.class);
+  }
+
+  @Test
+  public void toStringIncludesAllData_unresolvedImport() throws Exception {
+    CodeOwnerConfigImport unresolvedImport =
+        CodeOwnerConfigImport.createUnresolvedImport(
+            CodeOwnerConfig.Key.create(project, "master", "/"),
+            CodeOwnerConfig.Key.create(project, "master", "/bar/"),
+            CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS"),
+            "test message");
+    assertThatToStringIncludesAllData(unresolvedImport, CodeOwnerConfigImport.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigTest.java
index e3e9e6a..433922f 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigTest.java
@@ -60,7 +60,7 @@
   }
 
   @Test
-  public void getProject() throws Exception {
+  public void testGetProject() throws Exception {
     Project.NameKey project = Project.nameKey("project");
     CodeOwnerConfig.Key codeOwnerConfigKey =
         CodeOwnerConfig.Key.create(project, "master", "/foo/bar/");
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResultTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResultTest.java
index 18ea017..deab817 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResultTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResultTest.java
@@ -29,7 +29,17 @@
             /* annotations= */ ImmutableMultimap.of(),
             /* ownedByAllUsers= */ false,
             /* hasUnresolvedCodeOwners= */ false,
-            /* hasUnresolvedImports= */ false,
+            ImmutableList.of(
+                CodeOwnerConfigImport.createResolvedImport(
+                    CodeOwnerConfig.Key.create(project, "master", "/"),
+                    CodeOwnerConfig.Key.create(project, "master", "/bar/"),
+                    CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS"))),
+            ImmutableList.of(
+                CodeOwnerConfigImport.createUnresolvedImport(
+                    CodeOwnerConfig.Key.create(project, "master", "/"),
+                    CodeOwnerConfig.Key.create(project, "master", "/bar/"),
+                    CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS"),
+                    "test message")),
             ImmutableList.of("test message"));
     assertThatToStringIncludesAllData(codeOwnerResolverResult, CodeOwnerResolverResult.class);
   }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
index b32c3b5..6171aca 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.inject.Inject;
 import com.google.inject.Key;
@@ -257,7 +257,8 @@
         .hasMessagesThat()
         .contains(
             String.format(
-                "resolved code owner email %s: account %s is referenced by secondary email and the calling user %s can see secondary emails",
+                "resolved code owner email %s: account %s is referenced by secondary email and the"
+                    + " calling user %s can see secondary emails",
                 secondaryEmail, user.id(), admin.username()));
 
     // admin has the "Modify Account" global capability and hence can see the secondary email of the
@@ -273,7 +274,8 @@
         .hasMessagesThat()
         .contains(
             String.format(
-                "resolved code owner email %s: account %s is referenced by secondary email and user %s can see secondary emails",
+                "resolved code owner email %s: account %s is referenced by secondary email and user"
+                    + " %s can see secondary emails",
                 secondaryEmail, user.id(), admin.username()));
 
     // user can see its own secondary email.
@@ -287,7 +289,8 @@
         .hasMessagesThat()
         .contains(
             String.format(
-                "email %s is visible to the calling user %s: email is a secondary email that is owned by this user",
+                "email %s is visible to the calling user %s: email is a secondary email that is"
+                    + " owned by this user",
                 secondaryEmail, user.username()));
 
     // user can see its own secondary email if another user is the calling user.
@@ -302,7 +305,8 @@
         .hasMessagesThat()
         .contains(
             String.format(
-                "email %s is visible to user %s: email is a secondary email that is owned by this user",
+                "email %s is visible to user %s: email is a secondary email that is owned by this"
+                    + " user",
                 secondaryEmail, user.username()));
   }
 
@@ -324,7 +328,8 @@
         .hasMessagesThat()
         .contains(
             String.format(
-                "cannot resolve code owner email %s: account %s is referenced by secondary email but the calling user %s cannot see secondary emails",
+                "cannot resolve code owner email %s: account %s is referenced by secondary email"
+                    + " but the calling user %s cannot see secondary emails",
                 secondaryEmail, admin.id(), user.username()));
 
     // user doesn't have the "Modify Account" global capability and hence cannot see the secondary
@@ -340,7 +345,8 @@
         .hasMessagesThat()
         .contains(
             String.format(
-                "cannot resolve code owner email %s: account %s is referenced by secondary email but user %s cannot see secondary emails",
+                "cannot resolve code owner email %s: account %s is referenced by secondary email"
+                    + " but user %s cannot see secondary emails",
                 secondaryEmail, admin.id(), user.username()));
   }
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResultTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResultTest.java
index d270fdc..d9b3a71 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResultTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResultTest.java
@@ -27,19 +27,27 @@
   @Test
   public void toStringIncludesAllData() throws Exception {
     CodeOwnerConfig.Key codeOwnerConfigKey = CodeOwnerConfig.Key.create(project, "master", "/");
-    CodeOwnerConfigReference codeOwnerConfigReference =
+    CodeOwnerConfigReference resolvableCodeOwnerConfigReference =
         CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS");
+    CodeOwnerConfigReference unresolvableCodeOwnerConfigReference =
+        CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/baz/OWNERS");
     PathCodeOwnersResult pathCodeOwnersResult =
         PathCodeOwnersResult.create(
             Paths.get("/foo/bar/baz.md"),
             CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
-                .addImport(codeOwnerConfigReference)
+                .addImport(resolvableCodeOwnerConfigReference)
+                .addImport(unresolvableCodeOwnerConfigReference)
                 .build(),
             ImmutableList.of(
-                UnresolvedImport.create(
+                CodeOwnerConfigImport.createResolvedImport(
                     codeOwnerConfigKey,
                     CodeOwnerConfig.Key.create(project, "master", "/bar/"),
-                    codeOwnerConfigReference,
+                    resolvableCodeOwnerConfigReference)),
+            ImmutableList.of(
+                CodeOwnerConfigImport.createUnresolvedImport(
+                    codeOwnerConfigKey,
+                    CodeOwnerConfig.Key.create(project, "master", "/baz/"),
+                    unresolvableCodeOwnerConfigReference,
                     "test message")));
     assertThatToStringIncludesAllData(pathCodeOwnersResult, PathCodeOwnersResult.class);
   }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
index 9563ade..04df442 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
@@ -239,7 +239,8 @@
         pathCodeOwnersFactory.createWithoutCache(
             emptyCodeOwnerConfig, Paths.get("/foo/bar/baz.md"));
     assertThat(pathCodeOwners.resolveCodeOwnerConfig().get().getPathCodeOwners()).isEmpty();
-    assertThat(pathCodeOwners.resolveCodeOwnerConfig().get().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().get().resolvedImports()).isEmpty();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().get().unresolvedImports()).isEmpty();
   }
 
   @Test
@@ -471,6 +472,11 @@
 
   @Test
   public void nonResolveableImportIsIgnored() throws Exception {
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        CodeOwnerConfig.Key.create(project, "master", "/non-existing/", "OWNERS");
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
+
     // create importing config with non-resolveable import
     CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
@@ -478,9 +484,7 @@
             .project(project)
             .branch("master")
             .folderPath("/")
-            .addImport(
-                CodeOwnerConfigReference.create(
-                    CodeOwnerConfigImportMode.ALL, "/non-existing/OWNERS"))
+            .addImport(codeOwnerConfigReference)
             .addCodeOwnerSet(CodeOwnerSet.builder().addCodeOwnerEmail(admin.email()).build())
             .create();
 
@@ -494,24 +498,40 @@
 
     // Expectation: we get the global code owner from the importing code owner config, the
     // non-resolveable import is silently ignored
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports()).isTrue();
+    assertThat(pathCodeOwnersResult.resolvedImports()).isEmpty();
+    assertThat(pathCodeOwnersResult.unresolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createUnresolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference,
+                String.format(
+                    "code owner config does not exist (revision = %s)",
+                    projectOperations
+                        .project(keyOfImportedCodeOwnerConfig.project())
+                        .getHead(keyOfImportedCodeOwnerConfig.branchNameKey().branch())
+                        .name())));
   }
 
   @Test
   public void importOfNonCodeOwnerConfigFileIsIgnored() throws Exception {
     // create a file that looks like a code owner config file, but which has a name that is not
     // allowed as code owner config file
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/")
-        .fileName("FOO")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .fileName("FOO")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
 
     // create config with import of non code owner config file
     CodeOwnerConfig.Key importingCodeOwnerConfigKey =
@@ -520,7 +540,7 @@
             .project(project)
             .branch("master")
             .folderPath("/")
-            .addImport(CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/FOO"))
+            .addImport(codeOwnerConfigReference)
             .addCodeOwnerSet(CodeOwnerSet.builder().addCodeOwnerEmail(admin.email()).build())
             .create();
 
@@ -538,7 +558,19 @@
     assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwnersResult.hasUnresolvedImports()).isTrue();
+    assertThat(pathCodeOwnersResult.resolvedImports()).isEmpty();
+    assertThat(pathCodeOwnersResult.unresolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createUnresolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference,
+                String.format(
+                    "code owner config does not exist (revision = %s)",
+                    projectOperations
+                        .project(keyOfImportedCodeOwnerConfig.project())
+                        .getHead(keyOfImportedCodeOwnerConfig.branchNameKey().branch())
+                        .name())));
   }
 
   @Test
@@ -548,14 +580,17 @@
     // parameter fileExtension) or file extensions are enabled for code owner config files (config
     // paramater enableCodeOwnerConfigFilesWithFileExtensions). Both is not the case here, hence any
     // import of this file in another code owner config file should get ignored.
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/")
-        .fileName("OWNERS.foo")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .fileName("OWNERS.foo")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
 
     // create the importing config
     CodeOwnerConfig.Key importingCodeOwnerConfigKey =
@@ -564,8 +599,7 @@
             .project(project)
             .branch("master")
             .folderPath("/")
-            .addImport(
-                CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/OWNERS.FOO"))
+            .addImport(codeOwnerConfigReference)
             .addCodeOwnerSet(CodeOwnerSet.builder().addCodeOwnerEmail(admin.email()).build())
             .create();
 
@@ -584,7 +618,19 @@
     assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwnersResult.hasUnresolvedImports()).isTrue();
+    assertThat(pathCodeOwnersResult.resolvedImports()).isEmpty();
+    assertThat(pathCodeOwnersResult.unresolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createUnresolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference,
+                String.format(
+                    "code owner config does not exist (revision = %s)",
+                    projectOperations
+                        .project(keyOfImportedCodeOwnerConfig.project())
+                        .getHead(keyOfImportedCodeOwnerConfig.branchNameKey().branch())
+                        .name())));
   }
 
   @Test
@@ -595,14 +641,17 @@
     // Create a code owner config file with a file extension. This file is considered as a code
     // owner config file since file extensions for code owner config files are enabled (paramater
     // enableCodeOwnerConfigFilesWithFileExtensions).
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/")
-        .fileName("OWNERS.FOO")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .fileName("OWNERS.FOO")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
 
     // create the importing config
     CodeOwnerConfig.Key importingCodeOwnerConfigKey =
@@ -611,8 +660,7 @@
             .project(project)
             .branch("master")
             .folderPath("/")
-            .addImport(
-                CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/OWNERS.FOO"))
+            .addImport(codeOwnerConfigReference)
             .addCodeOwnerSet(CodeOwnerSet.builder().addCodeOwnerEmail(admin.email()).build())
             .create();
 
@@ -630,7 +678,13 @@
     assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwnersResult.hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
@@ -644,6 +698,19 @@
   }
 
   private void testImportGlobalCodeOwners(CodeOwnerConfigImportMode importMode) throws Exception {
+    // create imported config with global code owner
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(importMode, keyOfImportedCodeOwnerConfig);
+
     // create importing config with global code owner and import
     CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
@@ -652,18 +719,9 @@
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(CodeOwnerConfigReference.create(importMode, "/bar/OWNERS"))
+            .addImport(codeOwnerConfigReference)
             .create();
 
-    // create imported config with global code owner
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerEmail(user.email())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
@@ -674,17 +732,40 @@
 
     // Expectation: we get the global code owners from the importing and the imported code owner
     // config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
   public void importPerFileCodeOwners_importModeAll() throws Exception {
+    // create imported config with matching per-file code owner
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addPathExpression("*.md")
+                    .addCodeOwnerEmail(user.email())
+                    .build())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
+
     // create importing config with matching per-file code owner and import
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
@@ -695,44 +776,53 @@
                     .addPathExpression("*.md")
                     .addCodeOwnerEmail(admin.email())
                     .build())
-            .addImport(
-                CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS"))
+            .addImport(codeOwnerConfigReference)
             .create();
 
-    // create imported config with matching per-file code owner
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerSet(
-            CodeOwnerSet.builder()
-                .addPathExpression("*.md")
-                .addCodeOwnerEmail(user.email())
-                .build())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the matching per-file code owners from the importing and the imported
     // code owner config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
   public void nonMatchingPerFileCodeOwnersAreNotImported_importModeAll() throws Exception {
+    // create imported config with non-matching per-file code owner
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addPathExpression("*.txt")
+                    .addCodeOwnerEmail(user.email())
+                    .build())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
+
     // create importing config with matching per-file code owner and import
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
@@ -743,27 +833,13 @@
                     .addPathExpression("*.md")
                     .addCodeOwnerEmail(admin.email())
                     .build())
-            .addImport(
-                CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS"))
+            .addImport(codeOwnerConfigReference)
             .create();
 
-    // create imported config with non-matching per-file code owner
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerSet(
-            CodeOwnerSet.builder()
-                .addPathExpression("*.txt")
-                .addCodeOwnerEmail(user.email())
-                .build())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
@@ -771,17 +847,41 @@
     // 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().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
   public void perFileCodeOwnersAreNotImported_importModeGlobalCodeOwnerSetsOnly() throws Exception {
+    // create imported config with matching per-file code owner
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addPathExpression("*.md")
+                    .addCodeOwnerEmail(user.email())
+                    .build())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig);
+
     // create importing config with matching per-file code owner and import
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
@@ -792,28 +892,13 @@
                     .addPathExpression("*.md")
                     .addCodeOwnerEmail(admin.email())
                     .build())
-            .addImport(
-                CodeOwnerConfigReference.create(
-                    CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/bar/OWNERS"))
+            .addImport(codeOwnerConfigReference)
             .create();
 
-    // create imported config with matching per-file code owner
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerSet(
-            CodeOwnerSet.builder()
-                .addPathExpression("*.md")
-                .addCodeOwnerEmail(user.email())
-                .build())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
@@ -821,48 +906,57 @@
     // 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().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
   public void
       importIgnoreGlobalAndParentCodeOwnersFlagFromMatchingPerFileCodeOwnerSet_importModeAll()
           throws Exception {
+    // create imported config with matching per-file code owner that has the
+    // ignoreGlobalAndParentCodeOwners flag set to true
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .setIgnoreGlobalAndParentCodeOwners()
+                    .addPathExpression("*.md")
+                    .addCodeOwnerEmail(user.email())
+                    .build())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
+
     // create importing config with global code owner and import
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS"))
+            .addImport(codeOwnerConfigReference)
             .create();
 
-    // create imported config with matching per-file code owner that has the
-    // ignoreGlobalAndParentCodeOwners flag set to true
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerSet(
-            CodeOwnerSet.builder()
-                .setIgnoreGlobalAndParentCodeOwners()
-                .addPathExpression("*.md")
-                .addCodeOwnerEmail(user.email())
-                .build())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
@@ -872,50 +966,58 @@
     // 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().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().ignoreParentCodeOwners())
-        .isTrue();
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.ignoreParentCodeOwners()).isTrue();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
   public void
       ignoreGlobalAndParentCodeOwnersFlagIsNotImportedFromNonMatchingPerFileCodeOwnerSet_importModeAll()
           throws Exception {
+    // create imported config with non-matching per-file code owner that has the
+    // ignoreGlobalAndParentCodeOwners flag set to true
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .setIgnoreGlobalAndParentCodeOwners()
+                    .addPathExpression("*.txt")
+                    .addCodeOwnerEmail(user.email())
+                    .build())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
+
     // create importing config with global code owner and import
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS"))
+            .addImport(codeOwnerConfigReference)
             .create();
 
-    // create imported config with non-matching per-file code owner that has the
-    // ignoreGlobalAndParentCodeOwners flag set to true
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerSet(
-            CodeOwnerSet.builder()
-                .setIgnoreGlobalAndParentCodeOwners()
-                .addPathExpression("*.txt")
-                .addCodeOwnerEmail(user.email())
-                .build())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
@@ -924,50 +1026,58 @@
     // 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().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().ignoreParentCodeOwners())
-        .isFalse();
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
   public void ignoreGlobalAndParentCodeOwnersFlagIsNotImported_importModeGlobalCodeOwnerSetsOnly()
       throws Exception {
+    // create imported config with matching per-file code owner that has the
+    // ignoreGlobalAndParentCodeOwners flag set to true
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .setIgnoreGlobalAndParentCodeOwners()
+                    .addPathExpression("*.md")
+                    .addCodeOwnerEmail(user.email())
+                    .build())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig);
+
     // create importing config with global code owner and import
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.create(
-                    CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/bar/OWNERS"))
+            .addImport(codeOwnerConfigReference)
             .create();
 
-    // create imported config with matching per-file code owner that has the
-    // ignoreGlobalAndParentCodeOwners flag set to true
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerSet(
-            CodeOwnerSet.builder()
-                .setIgnoreGlobalAndParentCodeOwners()
-                .addPathExpression("*.md")
-                .addCodeOwnerEmail(user.email())
-                .build())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
@@ -976,89 +1086,114 @@
     // 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().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().ignoreParentCodeOwners())
-        .isFalse();
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
   public void importIgnoreParentCodeOwnersFlag_importModeAll() throws Exception {
+    // create imported config with the ignoreParentCodeOnwers flag set to true
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .ignoreParentCodeOwners()
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
+
     // create importing config
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS"))
+            .addImport(codeOwnerConfigReference)
             .create();
 
-    // create imported config with the ignoreParentCodeOnwers flag set to true
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .ignoreParentCodeOwners()
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: ignoreParentCodeOwners is true because the ignoreParentCodeOwners flag in the
     // imported code owner config is set to true
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().ignoreParentCodeOwners())
-        .isTrue();
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.ignoreParentCodeOwners()).isTrue();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
   public void ignoreParentCodeOwnersFlagNotImported_importModeGlobalCodeOwnerSetsOnly()
       throws Exception {
+    // create imported config with the ignoreParentCodeOnwers flag set to true
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .ignoreParentCodeOwners()
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig);
+
     // create importing config
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.create(
-                    CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/bar/OWNERS"))
+            .addImport(codeOwnerConfigReference)
             .create();
 
-    // create imported config with the ignoreParentCodeOnwers flag set to true
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .ignoreParentCodeOwners()
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // 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().resolveCodeOwnerConfig().get().ignoreParentCodeOwners())
-        .isFalse();
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
@@ -1077,51 +1212,69 @@
       throws Exception {
     TestAccount user2 = accountCreator.user2();
 
+    // create config with global code owner that is imported by the imported config
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/baz/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user2.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference2 =
+        createCodeOwnerConfigReference(importMode, keyOfImportedCodeOwnerConfig2);
+
+    // create imported config with global code owner and import
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user.email())
+            .addImport(codeOwnerConfigReference2)
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference1 =
+        createCodeOwnerConfigReference(importMode, keyOfImportedCodeOwnerConfig1);
+
     // create importing config with global code owner and import
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(CodeOwnerConfigReference.create(importMode, "/bar/OWNERS"))
+            .addImport(codeOwnerConfigReference1)
             .create();
 
-    // create imported config with global code owner and import
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerEmail(user.email())
-        .addImport(CodeOwnerConfigReference.create(importMode, "/baz/OWNERS"))
-        .create();
-
-    // create config with global code owner that is imported by the imported config
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/baz/")
-        .addCodeOwnerEmail(user2.email())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // 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().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email(), user2.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig1,
+                codeOwnerConfigReference1),
+            CodeOwnerConfigImport.createResolvedImport(
+                keyOfImportedCodeOwnerConfig1,
+                keyOfImportedCodeOwnerConfig2,
+                codeOwnerConfigReference2));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
@@ -1130,47 +1283,54 @@
           throws Exception {
     TestAccount user2 = accountCreator.user2();
 
+    // create config with per file code owner that is imported by the imported config
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/baz/")
+            .fileName("OWNERS")
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addPathExpression("foo.md")
+                    .addCodeOwnerEmail(user2.email())
+                    .build())
+            .create();
+
+    // create imported config with global code owner and import with import mode ALL
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user.email())
+            .addImport(
+                createCodeOwnerConfigReference(
+                    CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig2))
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference1 =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig1);
+
     // create importing config with global code owner and import with import mode
     // GLOBAL_CODE_OWNER_SETS_ONLY
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.create(
-                    CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/bar/OWNERS"))
+            .addImport(codeOwnerConfigReference1)
             .create();
 
-    // create imported config with global code owner and import with import mode ALL
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerEmail(user.email())
-        .addImport(CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/baz/OWNERS"))
-        .create();
-
-    // create config with per file code owner that is imported by the imported config
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/baz/")
-        .addCodeOwnerSet(
-            CodeOwnerSet.builder()
-                .addPathExpression("foo.md")
-                .addCodeOwnerEmail(user2.email())
-                .build())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
@@ -1178,11 +1338,23 @@
     // 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().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig1,
+                codeOwnerConfigReference1),
+            CodeOwnerConfigImport.createResolvedImport(
+                keyOfImportedCodeOwnerConfig1,
+                keyOfImportedCodeOwnerConfig2,
+                createCodeOwnerConfigReference(
+                    CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+                    keyOfImportedCodeOwnerConfig2)));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
@@ -1197,6 +1369,21 @@
 
   private void testImportCodeOwnerConfigWithNameExtension(String nameOfImportedCodeOwnerConfig)
       throws Exception {
+    // create imported config with global code owner
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .fileName(nameOfImportedCodeOwnerConfig)
+            .addCodeOwnerEmail(user.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig);
+
     // create importing config with global code owner and import
     CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
@@ -1205,22 +1392,9 @@
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.create(
-                    CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
-                    "/bar/" + nameOfImportedCodeOwnerConfig))
+            .addImport(codeOwnerConfigReference)
             .create();
 
-    // create imported config with global code owner
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .fileName(nameOfImportedCodeOwnerConfig)
-        .addCodeOwnerEmail(user.email())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
@@ -1231,47 +1405,74 @@
 
     // Expectation: we get the global code owners from the importing and the imported code owner
     // config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
   public void cyclicImports() throws Exception {
+    CodeOwnerConfigReference codeOwnerConfigReference2 =
+        CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/OWNERS");
+
+    // create imported config with global code owner and that imports the importing config
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user.email())
+            .addImport(codeOwnerConfigReference2)
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference1 =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
+
     // create importing config with global code owner and import
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
             .branch("master")
             .folderPath("/")
+            .fileName("OWNERS")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS"))
+            .addImport(codeOwnerConfigReference1)
             .create();
 
-    // create imported config with global code owner and that imports the importing config
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerEmail(user.email())
-        .addImport(CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/OWNERS"))
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference1),
+            CodeOwnerConfigImport.createResolvedImport(
+                keyOfImportedCodeOwnerConfig,
+                importingCodeOwnerConfigKey,
+                codeOwnerConfigReference2));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
@@ -1327,72 +1528,91 @@
 
   @Test
   public void importWithRelativePath() throws Exception {
+    // create imported config with global code owner
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/baz/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
+
     // create importing config with global code owner and import with relative path
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
             .branch("master")
             .folderPath("/foo/bar/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "../baz/OWNERS"))
+            .addImport(codeOwnerConfigReference)
             .create();
 
-    // create imported config with global code owner
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/baz/")
-        .addCodeOwnerEmail(user.email())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo/bar/baz.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
   public void importFromNonExistingProjectIsIgnored() throws Exception {
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        CodeOwnerConfig.Key.create(Project.nameKey("non-existing"), "master", "/bar/", "OWNERS");
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
+
     // create importing config with global code owner and import from non-existing project
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.builder(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS")
-                    .setProject(Project.nameKey("non-existing"))
-                    .build())
+            .addImport(codeOwnerConfigReference)
             .create();
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo/bar/baz.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing code owner config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports()).isTrue();
+    assertThat(pathCodeOwnersResult.resolvedImports()).isEmpty();
+    assertThat(pathCodeOwnersResult.unresolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createUnresolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference,
+                String.format("project %s not found", keyOfImportedCodeOwnerConfig.project())));
   }
 
   @Test
@@ -1402,32 +1622,33 @@
     ConfigInput configInput = new ConfigInput();
     configInput.state = ProjectState.HIDDEN;
     gApi.projects().name(hiddenProject.get()).config(configInput);
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(hiddenProject)
-        .branch("master")
-        .folderPath("/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(hiddenProject)
+            .branch("master")
+            .folderPath("/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
 
     // create importing config with global code owner and import from the hidden project
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.builder(CodeOwnerConfigImportMode.ALL, "/OWNERS")
-                    .setProject(hiddenProject)
-                    .build())
+            .addImport(codeOwnerConfigReference)
             .create();
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo/bar/baz.md"));
     assertThat(pathCodeOwners).isPresent();
@@ -1435,85 +1656,111 @@
     // 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().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports()).isTrue();
+    assertThat(pathCodeOwnersResult.resolvedImports()).isEmpty();
+    assertThat(pathCodeOwnersResult.unresolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createUnresolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference,
+                String.format(
+                    "state of project %s doesn't permit read",
+                    keyOfImportedCodeOwnerConfig.project())));
   }
 
   @Test
   public void importFromNonExistingBranchIsIgnored() throws Exception {
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        CodeOwnerConfig.Key.create(project, "non-existing", "/bar/", "OWNERS");
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
+
     // create importing config with global code owner and import from non-existing branch
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.builder(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS")
-                    .setProject(project)
-                    .setBranch("non-existing")
-                    .build())
+            .addImport(codeOwnerConfigReference)
             .create();
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo/bar/baz.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing code owner config
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
     assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports()).isTrue();
+    assertThat(pathCodeOwnersResult.resolvedImports()).isEmpty();
+    assertThat(pathCodeOwnersResult.unresolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createUnresolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference,
+                "code owner config does not exist (revision = current)"));
   }
 
   @Test
   public void importFromOtherProject() throws Exception {
     Project.NameKey otherProject = projectOperations.newProject().create();
 
+    // create imported config with global code owner
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(otherProject)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
+
     // create importing config with global code owner and import with relative path
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.builder(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS")
-                    .setProject(otherProject)
-                    .build())
+            .addImport(codeOwnerConfigReference)
             .create();
 
-    // create imported config with global code owner
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(otherProject)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerEmail(user.email())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo/bar/baz.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
@@ -1527,43 +1774,52 @@
     // Create other branches in other project.
     createBranch(BranchNameKey.create(otherProject, branchName));
 
+    // create imported config with global code owner
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(otherProject)
+            .branch(branchName)
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        CodeOwnerConfigReference.builder(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS")
+            .setProject(otherProject)
+            .build();
+
     // create importing config with global code owner and import with relative path
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
             .branch(branchName)
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.builder(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS")
-                    .setProject(otherProject)
-                    .build())
+            .addImport(codeOwnerConfigReference)
             .create();
 
-    // create imported config with global code owner
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(otherProject)
-        .branch(branchName)
-        .folderPath("/bar/")
-        .addCodeOwnerEmail(user.email())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead(branchName),
             Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
@@ -1572,44 +1828,50 @@
     String otherBranch = "foo";
     createBranch(BranchNameKey.create(project, otherBranch));
 
+    // create imported config with global code owner
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch(otherBranch)
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
+
     // create importing config with global code owner and import with relative path
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.builder(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS")
-                    .setProject(project)
-                    .setBranch(otherBranch)
-                    .build())
+            .addImport(codeOwnerConfigReference)
             .create();
 
-    // create imported config with global code owner
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch(otherBranch)
-        .folderPath("/bar/")
-        .addCodeOwnerEmail(user.email())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo/bar/baz.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
@@ -1620,48 +1882,60 @@
     String otherBranch = "foo";
     createBranch(BranchNameKey.create(otherProject, otherBranch));
 
+    // create imported config with global code owner
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(otherProject)
+            .branch(otherBranch)
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
+
     // create importing config with global code owner and import with relative path
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.builder(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS")
-                    .setProject(otherProject)
-                    .setBranch(otherBranch)
-                    .build())
+            .addImport(codeOwnerConfigReference)
             .create();
 
-    // create imported config with global code owner
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(otherProject)
-        .branch(otherBranch)
-        .folderPath("/bar/")
-        .addCodeOwnerEmail(user.email())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo/bar/baz.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
   public void nonResolveablePerFileImportIsIgnored() throws Exception {
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        CodeOwnerConfig.Key.create(project, "master", "/non-existing/", "OWNERS");
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig);
+
     // create importing config with non-resolveable per file import
     CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
@@ -1673,10 +1947,7 @@
                 CodeOwnerSet.builder()
                     .addCodeOwnerEmail(admin.email())
                     .addPathExpression("foo.md")
-                    .addImport(
-                        CodeOwnerConfigReference.create(
-                            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
-                            "/non-existing/OWNERS"))
+                    .addImport(codeOwnerConfigReference)
                     .build())
             .create();
 
@@ -1690,16 +1961,50 @@
 
     // 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().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports()).isTrue();
+    assertThat(pathCodeOwnersResult.resolvedImports()).isEmpty();
+    assertThat(pathCodeOwnersResult.unresolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createUnresolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference,
+                String.format(
+                    "code owner config does not exist (revision = %s)",
+                    projectOperations
+                        .project(keyOfImportedCodeOwnerConfig.project())
+                        .getHead(keyOfImportedCodeOwnerConfig.branchNameKey().branch())
+                        .name())));
   }
 
   @Test
   public void perFileImport() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
+    // create imported config with ignoreParentCodeOwners = true, a global code owner and a per file
+    // code owner
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .ignoreParentCodeOwners()
+            .addCodeOwnerEmail(user.email())
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addPathExpression("foo.md")
+                    .addCodeOwnerEmail(user2.email())
+                    .build())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig);
+
     // create importing config with per code owner and per file import
     CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
@@ -1711,28 +2016,10 @@
                 CodeOwnerSet.builder()
                     .addPathExpression("foo.md")
                     .addCodeOwnerEmail(admin.email())
-                    .addImport(
-                        CodeOwnerConfigReference.create(
-                            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/bar/OWNERS"))
+                    .addImport(codeOwnerConfigReference)
                     .build())
             .create();
 
-    // create imported config with ignoreParentCodeOwners = true, a global code owner and a per file
-    // code owner
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .ignoreParentCodeOwners()
-        .addCodeOwnerEmail(user.email())
-        .addCodeOwnerSet(
-            CodeOwnerSet.builder()
-                .addPathExpression("foo.md")
-                .addCodeOwnerEmail(user2.email())
-                .build())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
@@ -1744,23 +2031,58 @@
     // 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().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
 
     // Expectation: the ignoreParentCodeOwners flag from the imported code owner config is ignored
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().ignoreParentCodeOwners())
-        .isFalse();
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.ignoreParentCodeOwners()).isFalse();
+
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
   public void importsOfPerFileImportedCodeOwnerConfigAreResolved() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
+    // create config with global code owner that is imported by the imported config
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/baz/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user2.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference2 =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig2);
+
+    // create imported config with global code owner and global import
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user.email())
+            .addImport(codeOwnerConfigReference2)
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference1 =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig1);
+
     // create importing config with per file code owner and per file import
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
@@ -1770,56 +2092,75 @@
                 CodeOwnerSet.builder()
                     .addPathExpression("foo.md")
                     .addCodeOwnerEmail(admin.email())
-                    .addImport(
-                        CodeOwnerConfigReference.create(
-                            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/bar/OWNERS"))
+                    .addImport(codeOwnerConfigReference1)
                     .build())
             .create();
 
-    // create imported config with global code owner and global import
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerEmail(user.email())
-        .addImport(
-            CodeOwnerConfigReference.create(
-                CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/baz/OWNERS"))
-        .create();
-
-    // create config with global code owner that is imported by the imported config
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/baz/")
-        .addCodeOwnerEmail(user2.email())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // 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().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email(), user2.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig1,
+                codeOwnerConfigReference1),
+            CodeOwnerConfigImport.createResolvedImport(
+                keyOfImportedCodeOwnerConfig1,
+                keyOfImportedCodeOwnerConfig2,
+                codeOwnerConfigReference2));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
   public void onlyGlobalCodeOwnersAreImportedForTransitivePerFileImports() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
+    // create config with per file code owner that is imported by the imported config
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/baz/")
+            .fileName("OWNERS")
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addPathExpression("foo.md")
+                    .addCodeOwnerEmail(user2.email())
+                    .build())
+            .create();
+
+    // create imported config with per global owner and global import with mode ALL
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user.email())
+            .addImport(
+                createCodeOwnerConfigReference(
+                    CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig2))
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference1 =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig1);
+
     // create importing config with per file code owner and per file import
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
@@ -1829,39 +2170,14 @@
                 CodeOwnerSet.builder()
                     .addPathExpression("foo.md")
                     .addCodeOwnerEmail(admin.email())
-                    .addImport(
-                        CodeOwnerConfigReference.create(
-                            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/bar/OWNERS"))
+                    .addImport(codeOwnerConfigReference1)
                     .build())
             .create();
 
-    // create imported config with per global owner and global import with mode ALL
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerEmail(user.email())
-        .addImport(CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/baz/OWNERS"))
-        .create();
-
-    // create config with per file code owner that is imported by the imported config
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/baz/")
-        .addCodeOwnerSet(
-            CodeOwnerSet.builder()
-                .addPathExpression("foo.md")
-                .addCodeOwnerEmail(user2.email())
-                .build())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
@@ -1869,104 +2185,156 @@
     // 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().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig1,
+                codeOwnerConfigReference1),
+            CodeOwnerConfigImport.createResolvedImport(
+                keyOfImportedCodeOwnerConfig1,
+                keyOfImportedCodeOwnerConfig2,
+                createCodeOwnerConfigReference(
+                    CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+                    keyOfImportedCodeOwnerConfig2)));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
   public void onlyMatchingTransitivePerFileImportsAreImported() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
+    // create config with global code owner that is imported by the imported config for *.md files
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/md/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference2 =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig2);
+
+    // create config with global code owner that is imported by the imported config for *.txt files
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig3 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/txt/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user2.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference3 =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig3);
+
+    // create imported config with 2 per file imports, one for *.md files and one for *.txt
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addPathExpression("*.md")
+                    .addImport(codeOwnerConfigReference2)
+                    .build())
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addPathExpression("*.txt")
+                    .addImport(codeOwnerConfigReference3)
+                    .build())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference1 =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig1);
+
     // create importing config with global import
-    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
             .newCodeOwnerConfig()
             .project(project)
             .branch("master")
             .folderPath("/")
-            .addImport(
-                CodeOwnerConfigReference.create(
-                    CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/bar/OWNERS"))
+            .addImport(codeOwnerConfigReference1)
             .create();
 
-    // create imported config with 2 per file imports, one for *.md files and one for *.txt
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerSet(
-            CodeOwnerSet.builder()
-                .addPathExpression("*.md")
-                .addImport(
-                    CodeOwnerConfigReference.create(
-                        CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/md/OWNERS"))
-                .build())
-        .addCodeOwnerSet(
-            CodeOwnerSet.builder()
-                .addPathExpression("*.txt")
-                .addImport(
-                    CodeOwnerConfigReference.create(
-                        CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/txt/OWNERS"))
-                .build())
-        .create();
-
-    // create config with global code owner that is imported by the imported config for *.md files
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/md/")
-        .addCodeOwnerEmail(user.email())
-        .create();
-
-    // create config with global code owner that is imported by the imported config for *.txt files
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/txt/")
-        .addCodeOwnerEmail(user2.email())
-        .create();
-
     // Expectation for foo.xyz file: code owners is empty since foo.xyz neither matches *.md nor
     // *.txt
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.xyz"));
     assertThat(pathCodeOwners).isPresent();
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners()).isEmpty();
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners()).isEmpty();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig1,
+                codeOwnerConfigReference1));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
 
     // Expectation for foo.md file: code owners contains only user since foo.md only matches *.md
     pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(user.email());
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig1,
+                codeOwnerConfigReference1),
+            CodeOwnerConfigImport.createResolvedImport(
+                keyOfImportedCodeOwnerConfig1,
+                keyOfImportedCodeOwnerConfig2,
+                codeOwnerConfigReference2));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
 
     // Expectation for foo.txt file: code owners contains only user2 since foo.txt only matches
     // *.txt
     pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
-            rootCodeOwnerConfigKey,
+            importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.txt"));
     assertThat(pathCodeOwners).isPresent();
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(user2.email());
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig1,
+                codeOwnerConfigReference1),
+            CodeOwnerConfigImport.createResolvedImport(
+                keyOfImportedCodeOwnerConfig1,
+                keyOfImportedCodeOwnerConfig3,
+                codeOwnerConfigReference3));
   }
 
   @Test
@@ -2090,6 +2458,20 @@
   @Test
   public void perFileRuleThatIgnoresGlobalCodeOwnersCanImportGlobalCodeOwnersFromOtherFile()
       throws Exception {
+    // create imported config with a global code owner
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig);
+
     // create importing config that:
     // * has a global code owner
     // * has a per-file import for md files
@@ -2105,21 +2487,10 @@
                 CodeOwnerSet.builder()
                     .addPathExpression(testPathExpressions.matchFileType("md"))
                     .setIgnoreGlobalAndParentCodeOwners()
-                    .addImport(
-                        CodeOwnerConfigReference.create(
-                            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/bar/OWNERS"))
+                    .addImport(codeOwnerConfigReference)
                     .build())
             .create();
 
-    // create imported config with a global code owner
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerEmail(user.email())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
@@ -2131,11 +2502,17 @@
     // Expectation: we get the global code owner from the imported code owner config (since it is
     // imported by a matching per-file rule), the global code owner from the importing code owner
     // config is ignored (since the matching per-file rule ignores parent and global code owners)
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
@@ -2146,6 +2523,24 @@
     TestAccount user3 =
         accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
 
+    // create imported config that has a matching per-file rule
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user2.email())
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addPathExpression(testPathExpressions.matchFileType("md"))
+                    .addCodeOwnerEmail(user3.email())
+                    .build())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, keyOfImportedCodeOwnerConfig);
+
     // create importing config that has a global import with mode ALL and a per-file rule for md
     // files that ignores global and parent code owners
     CodeOwnerConfig.Key importingCodeOwnerConfigKey =
@@ -2155,8 +2550,7 @@
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS"))
+            .addImport(codeOwnerConfigReference)
             .addCodeOwnerSet(
                 CodeOwnerSet.builder()
                     .addPathExpression(testPathExpressions.matchFileType("md"))
@@ -2165,20 +2559,6 @@
                     .build())
             .create();
 
-    // create imported config that has a matching per-file rule
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerEmail(user2.email())
-        .addCodeOwnerSet(
-            CodeOwnerSet.builder()
-                .addPathExpression(testPathExpressions.matchFileType("md"))
-                .addCodeOwnerEmail(user3.email())
-                .build())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
@@ -2191,11 +2571,17 @@
     // owner config and the code owner from the matching per-file rule in the imported code owner
     // config, the global code owners are ignored since there is a matching per-file rule that
     // ignores parent and global code owners
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(user.email(), user3.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig,
+                codeOwnerConfigReference));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
@@ -2204,6 +2590,35 @@
 
     Project.NameKey otherProject = projectOperations.newProject().create();
 
+    // create transitively imported config in other project
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(otherProject)
+            .branch("master")
+            .folderPath("/baz/")
+            .fileName("OWNERS")
+            .addCodeOwnerSet(CodeOwnerSet.builder().addCodeOwnerEmail(user2.email()).build())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference2 =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig2);
+
+    // create imported config in other project
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(otherProject)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user.email())
+            .addImport(codeOwnerConfigReference2)
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference1 =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig1);
+
     // create importing config
     CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
@@ -2212,32 +2627,9 @@
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.builder(
-                        CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/bar/OWNERS")
-                    .setProject(otherProject)
-                    .build())
+            .addImport(codeOwnerConfigReference1)
             .create();
 
-    // create imported config in other project
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(otherProject)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerEmail(user.email())
-        .addImport(CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/baz/OWNERS"))
-        .create();
-
-    // create transitively imported config in other project
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(otherProject)
-        .branch("master")
-        .folderPath("/baz/")
-        .addCodeOwnerSet(CodeOwnerSet.builder().addCodeOwnerEmail(user2.email()).build())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
@@ -2248,11 +2640,21 @@
 
     // Expectation: we get the global owners from the importing code owner config and from the
     // directly and transitively imported code owner configs in the other project
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email(), user2.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig1,
+                codeOwnerConfigReference1),
+            CodeOwnerConfigImport.createResolvedImport(
+                keyOfImportedCodeOwnerConfig1,
+                keyOfImportedCodeOwnerConfig2,
+                codeOwnerConfigReference2));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   @Test
@@ -2261,6 +2663,35 @@
 
     Project.NameKey otherProject = projectOperations.newProject().create();
 
+    // create transitively imported config
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(otherProject)
+            .branch("master")
+            .folderPath("/bar/baz/")
+            .fileName("OWNERS")
+            .addCodeOwnerSet(CodeOwnerSet.builder().addCodeOwnerEmail(user2.email()).build())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference2 =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig2);
+
+    // create imported config
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(otherProject)
+            .branch("master")
+            .folderPath("/bar/")
+            .fileName("OWNERS")
+            .addCodeOwnerEmail(user.email())
+            .addImport(codeOwnerConfigReference2)
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference1 =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig1);
+
     // create importing config
     CodeOwnerConfig.Key importingCodeOwnerConfigKey =
         codeOwnerConfigOperations
@@ -2269,32 +2700,9 @@
             .branch("master")
             .folderPath("/")
             .addCodeOwnerEmail(admin.email())
-            .addImport(
-                CodeOwnerConfigReference.builder(
-                        CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "bar/OWNERS")
-                    .setProject(otherProject)
-                    .build())
+            .addImport(codeOwnerConfigReference1)
             .create();
 
-    // create imported config
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(otherProject)
-        .branch("master")
-        .folderPath("/bar/")
-        .addCodeOwnerEmail(user.email())
-        .addImport(CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "baz/OWNERS"))
-        .create();
-
-    // create transitively imported config
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(otherProject)
-        .branch("master")
-        .folderPath("/bar/baz/")
-        .addCodeOwnerSet(CodeOwnerSet.builder().addCodeOwnerEmail(user2.email()).build())
-        .create();
-
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
             transientCodeOwnerConfigCacheProvider.get(),
@@ -2305,11 +2713,21 @@
 
     // Expectation: we get the global owners from the importing code owner config and from the
     // directly and transitively imported code owner configs
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
     assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email(), user2.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
-        .isFalse();
+    assertThat(pathCodeOwnersResult.resolvedImports())
+        .containsExactly(
+            CodeOwnerConfigImport.createResolvedImport(
+                importingCodeOwnerConfigKey,
+                keyOfImportedCodeOwnerConfig1,
+                codeOwnerConfigReference1),
+            CodeOwnerConfigImport.createResolvedImport(
+                keyOfImportedCodeOwnerConfig1,
+                keyOfImportedCodeOwnerConfig2,
+                codeOwnerConfigReference2));
+    assertThat(pathCodeOwnersResult.unresolvedImports()).isEmpty();
   }
 
   private CodeOwnerConfig.Builder createCodeOwnerBuilder() {
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/UnresolvedImportTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/UnresolvedImportTest.java
deleted file mode 100644
index ce0494f..0000000
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/UnresolvedImportTest.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.plugins.codeowners.backend;
-
-import org.junit.Test;
-
-/** Tests for {@link UnresolvedImport}. */
-public class UnresolvedImportTest extends AbstractAutoValueTest {
-  @Test
-  public void toStringIncludesAllData() throws Exception {
-    UnresolvedImport unresolvedImport =
-        UnresolvedImport.create(
-            CodeOwnerConfig.Key.create(project, "master", "/"),
-            CodeOwnerConfig.Key.create(project, "master", "/bar/"),
-            CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS"),
-            "test message");
-    assertThatToStringIncludesAllData(unresolvedImport, UnresolvedImport.class);
-  }
-}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
index 970aa74..17663b2 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_ASYNC_MESSAGE_ON_ADD_REVIEWER;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_ASYNC_MESSAGE_ON_CODE_OWNER_APPROVAL;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_CODE_OWNER_CONFIG_FILES_WITH_FILE_EXTENSIONS;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_IMPLICIT_APPROVALS;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_STICKY_APPROVALS;
@@ -52,12 +53,19 @@
 import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.testing.ConfigSuite;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Test;
 
 /** Tests for {@link com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig}. */
 public class GeneralConfigTest extends AbstractCodeOwnersTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    // Override the settings from AbstractCodeOwnersTest.defaultConfig().
+    return new Config();
+  }
+
   private GeneralConfig generalConfig;
 
   @Before
@@ -2104,4 +2112,76 @@
       throws Exception {
     assertThat(generalConfig.enableAsyncMessageOnAddReviewer(project, new Config())).isTrue();
   }
+
+  @Test
+  public void cannotGetEnableAsyncMessageOnCodeOwnerApprovalForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.enableAsyncMessageOnCodeOwnerApproval(
+                    /* project= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetEnableAsyncMessageOnCodeOwnerApprovalForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.enableAsyncMessageOnCodeOwnerApproval(
+                    project, /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noEnableAsyncMessageOnCodeOwnerApproval() throws Exception {
+    assertThat(generalConfig.enableAsyncMessageOnCodeOwnerApproval(project, new Config())).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableAsyncMessageOnCodeOwnerApproval", value = "false")
+  public void
+      enableAsyncMessageOnCodeOwnerApprovalConfigurationIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+          throws Exception {
+    assertThat(generalConfig.enableAsyncMessageOnCodeOwnerApproval(project, new Config()))
+        .isFalse();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableAsyncMessageOnCodeOwnerApproval", value = "false")
+  public void
+      enableAsyncMessageOnCodeOwnerApprovalConfigurationInPluginConfigOverridesEnableAsyncMessageOnCodeOwnerApprovalConfigurationInGerritConfig()
+          throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        KEY_ENABLE_ASYNC_MESSAGE_ON_CODE_OWNER_APPROVAL,
+        "true");
+    assertThat(generalConfig.enableAsyncMessageOnCodeOwnerApproval(project, cfg)).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableAsyncMessageOnCodeOwnerApproval", value = "false")
+  public void invalidEnableAsyncMessageOnCodeOwnerApprovalConfigurationInPluginConfigIsIgnored()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        KEY_ENABLE_ASYNC_MESSAGE_ON_CODE_OWNER_APPROVAL,
+        "INVALID");
+    assertThat(generalConfig.enableAsyncMessageOnCodeOwnerApproval(project, cfg)).isFalse();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableAsyncMessageOnCodeOwnerApproval",
+      value = "INVALID")
+  public void invalidEnableAsyncMessageOnCodeOwnerApprovalConfigurationInGerritConfigIsIgnored()
+      throws Exception {
+    assertThat(generalConfig.enableAsyncMessageOnCodeOwnerApproval(project, new Config())).isTrue();
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerConfigFileJsonIT.java b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerConfigFileJsonIT.java
new file mode 100644
index 0000000..ed0b301
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerConfigFileJsonIT.java
@@ -0,0 +1,388 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.restapi;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigFileInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImport;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportMode;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigReference;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link CodeOwnerConfigFileJson}. */
+public class CodeOwnerConfigFileJsonIT extends AbstractCodeOwnersIT {
+  private CodeOwnerConfigFileJson CodeOwnerConfigFileJson;
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    CodeOwnerConfigFileJson = plugin.getSysInjector().getInstance(CodeOwnerConfigFileJson.class);
+  }
+
+  @Test
+  public void cannotFormatWithNullCodeOwnerConfigKey() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                CodeOwnerConfigFileJson.format(
+                    /* codeOwnerConfigKey= */ null,
+                    /* resolvedImports= */ ImmutableList.of(),
+                    /* unresolvedImports= */ ImmutableList.of()));
+    assertThat(npe).hasMessageThat().isEqualTo("codeOwnerConfigKey");
+  }
+
+  @Test
+  public void cannotFormatWithNullResolvedImports() throws Exception {
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        CodeOwnerConfig.Key.create(Project.nameKey("project"), "master", "/");
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                CodeOwnerConfigFileJson.format(
+                    codeOwnerConfigKey,
+                    /* resolvedImports= */ null,
+                    /* unresolvedImports= */ ImmutableList.of()));
+    assertThat(npe).hasMessageThat().isEqualTo("resolvedImports");
+  }
+
+  @Test
+  public void cannotFormatWithNullUnresolvedImports() throws Exception {
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        CodeOwnerConfig.Key.create(Project.nameKey("project"), "master", "/");
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                CodeOwnerConfigFileJson.format(
+                    codeOwnerConfigKey,
+                    /* resolvedImports= */ ImmutableList.of(),
+                    /* unresolvedImports= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("unresolvedImports");
+  }
+
+  @Test
+  public void formatWithoutImports() throws Exception {
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/baz/")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    CodeOwnerConfigFileInfo codeOwnerConfigFileInfo =
+        CodeOwnerConfigFileJson.format(
+            codeOwnerConfigKey,
+            /* resolvedImports= */ ImmutableList.of(),
+            /* unresolvedImports= */ ImmutableList.of());
+    assertThat(codeOwnerConfigFileInfo.project).isEqualTo(codeOwnerConfigKey.project().get());
+    assertThat(codeOwnerConfigFileInfo.branch)
+        .isEqualTo(codeOwnerConfigKey.branchNameKey().branch());
+    assertThat(codeOwnerConfigFileInfo.path)
+        .isEqualTo(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath());
+    assertThat(codeOwnerConfigFileInfo.importMode).isNull();
+    assertThat(codeOwnerConfigFileInfo.imports).isNull();
+    assertThat(codeOwnerConfigFileInfo.unresolvedImports).isNull();
+    assertThat(codeOwnerConfigFileInfo.unresolvedErrorMessage).isNull();
+  }
+
+  @Test
+  public void formatWithUnresolvedImports() throws Exception {
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/bar/")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        CodeOwnerConfig.Key.create(project, "stable", "/foo/baz/");
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig);
+
+    CodeOwnerConfigFileInfo codeOwnerConfigFileInfo =
+        CodeOwnerConfigFileJson.format(
+            codeOwnerConfigKey,
+            /* resolvedImports= */ ImmutableList.of(),
+            /* unresolvedImports= */ ImmutableList.of(
+                CodeOwnerConfigImport.createUnresolvedImport(
+                    codeOwnerConfigKey,
+                    keyOfImportedCodeOwnerConfig,
+                    codeOwnerConfigReference,
+                    "error message")));
+    assertThat(codeOwnerConfigFileInfo.project).isEqualTo(codeOwnerConfigKey.project().get());
+    assertThat(codeOwnerConfigFileInfo.branch)
+        .isEqualTo(codeOwnerConfigKey.branchNameKey().branch());
+    assertThat(codeOwnerConfigFileInfo.path)
+        .isEqualTo(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath());
+    assertThat(codeOwnerConfigFileInfo.importMode).isNull();
+    assertThat(codeOwnerConfigFileInfo.imports).isNull();
+    assertThat(codeOwnerConfigFileInfo.unresolvedErrorMessage).isNull();
+
+    assertThat(codeOwnerConfigFileInfo.unresolvedImports).hasSize(1);
+    CodeOwnerConfigFileInfo unresolvedImportInfo =
+        Iterables.getOnlyElement(codeOwnerConfigFileInfo.unresolvedImports);
+    assertThat(unresolvedImportInfo.project)
+        .isEqualTo(keyOfImportedCodeOwnerConfig.project().get());
+    assertThat(unresolvedImportInfo.branch)
+        .isEqualTo(keyOfImportedCodeOwnerConfig.branchNameKey().branch());
+    assertThat(unresolvedImportInfo.path)
+        .isEqualTo(
+            codeOwnerConfigOperations.codeOwnerConfig(keyOfImportedCodeOwnerConfig).getFilePath());
+    assertThat(unresolvedImportInfo.importMode).isEqualTo(codeOwnerConfigReference.importMode());
+    assertThat(unresolvedImportInfo.imports).isNull();
+    assertThat(unresolvedImportInfo.unresolvedImports).isNull();
+    assertThat(unresolvedImportInfo.unresolvedErrorMessage).isEqualTo("error message");
+  }
+
+  @Test
+  public void formatWithImports() throws Exception {
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/bar/")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        CodeOwnerConfig.Key.create(project, "stable", "/foo/baz/");
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig);
+
+    CodeOwnerConfigFileInfo codeOwnerConfigFileInfo =
+        CodeOwnerConfigFileJson.format(
+            codeOwnerConfigKey,
+            /* resolvedImports= */ ImmutableList.of(
+                CodeOwnerConfigImport.createResolvedImport(
+                    codeOwnerConfigKey, keyOfImportedCodeOwnerConfig, codeOwnerConfigReference)),
+            /* unresolvedImports= */ ImmutableList.of());
+    assertThat(codeOwnerConfigFileInfo.project).isEqualTo(codeOwnerConfigKey.project().get());
+    assertThat(codeOwnerConfigFileInfo.branch)
+        .isEqualTo(codeOwnerConfigKey.branchNameKey().branch());
+    assertThat(codeOwnerConfigFileInfo.path)
+        .isEqualTo(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath());
+    assertThat(codeOwnerConfigFileInfo.importMode).isNull();
+    assertThat(codeOwnerConfigFileInfo.unresolvedImports).isNull();
+    assertThat(codeOwnerConfigFileInfo.unresolvedErrorMessage).isNull();
+
+    assertThat(codeOwnerConfigFileInfo.imports).hasSize(1);
+    CodeOwnerConfigFileInfo resolvedImportInfo =
+        Iterables.getOnlyElement(codeOwnerConfigFileInfo.imports);
+    assertThat(resolvedImportInfo.project).isEqualTo(keyOfImportedCodeOwnerConfig.project().get());
+    assertThat(resolvedImportInfo.branch)
+        .isEqualTo(keyOfImportedCodeOwnerConfig.branchNameKey().branch());
+    assertThat(resolvedImportInfo.path)
+        .isEqualTo(
+            codeOwnerConfigOperations.codeOwnerConfig(keyOfImportedCodeOwnerConfig).getFilePath());
+    assertThat(resolvedImportInfo.importMode).isEqualTo(codeOwnerConfigReference.importMode());
+    assertThat(resolvedImportInfo.imports).isNull();
+    assertThat(resolvedImportInfo.unresolvedImports).isNull();
+    assertThat(resolvedImportInfo.unresolvedErrorMessage).isNull();
+  }
+
+  @Test
+  public void formatWithNestedImports() throws Exception {
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/bar/")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/bar/baz")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, keyOfImportedCodeOwnerConfig);
+
+    CodeOwnerConfig.Key keyOfNestedImportedCodeOwnerConfig1 =
+        CodeOwnerConfig.Key.create(project, "stable", "/foo/baz1/");
+    CodeOwnerConfigReference nestedCodeOwnerConfigReference1 =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+            keyOfNestedImportedCodeOwnerConfig1);
+
+    CodeOwnerConfig.Key keyOfNestedImportedCodeOwnerConfig2 =
+        CodeOwnerConfig.Key.create(project, "stable", "/foo/baz2/");
+    CodeOwnerConfigReference nestedCodeOwnerConfigReference2 =
+        createCodeOwnerConfigReference(
+            CodeOwnerConfigImportMode.ALL, keyOfNestedImportedCodeOwnerConfig2);
+
+    CodeOwnerConfigFileInfo codeOwnerConfigFileInfo =
+        CodeOwnerConfigFileJson.format(
+            codeOwnerConfigKey,
+            /* resolvedImports= */ ImmutableList.of(
+                CodeOwnerConfigImport.createResolvedImport(
+                    codeOwnerConfigKey, keyOfImportedCodeOwnerConfig, codeOwnerConfigReference),
+                CodeOwnerConfigImport.createResolvedImport(
+                    keyOfImportedCodeOwnerConfig,
+                    keyOfNestedImportedCodeOwnerConfig1,
+                    nestedCodeOwnerConfigReference1)),
+            /* unresolvedImports= */ ImmutableList.of(
+                CodeOwnerConfigImport.createUnresolvedImport(
+                    keyOfImportedCodeOwnerConfig,
+                    keyOfNestedImportedCodeOwnerConfig2,
+                    nestedCodeOwnerConfigReference2,
+                    "error message")));
+    assertThat(codeOwnerConfigFileInfo.project).isEqualTo(codeOwnerConfigKey.project().get());
+    assertThat(codeOwnerConfigFileInfo.branch)
+        .isEqualTo(codeOwnerConfigKey.branchNameKey().branch());
+    assertThat(codeOwnerConfigFileInfo.path)
+        .isEqualTo(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath());
+    assertThat(codeOwnerConfigFileInfo.importMode).isNull();
+    assertThat(codeOwnerConfigFileInfo.unresolvedImports).isNull();
+    assertThat(codeOwnerConfigFileInfo.unresolvedErrorMessage).isNull();
+
+    assertThat(codeOwnerConfigFileInfo.imports).hasSize(1);
+    CodeOwnerConfigFileInfo resolvedImportInfo =
+        Iterables.getOnlyElement(codeOwnerConfigFileInfo.imports);
+    assertThat(resolvedImportInfo.project).isEqualTo(keyOfImportedCodeOwnerConfig.project().get());
+    assertThat(resolvedImportInfo.branch)
+        .isEqualTo(keyOfImportedCodeOwnerConfig.branchNameKey().branch());
+    assertThat(resolvedImportInfo.path)
+        .isEqualTo(
+            codeOwnerConfigOperations.codeOwnerConfig(keyOfImportedCodeOwnerConfig).getFilePath());
+    assertThat(resolvedImportInfo.importMode).isEqualTo(codeOwnerConfigReference.importMode());
+    assertThat(resolvedImportInfo.unresolvedErrorMessage).isNull();
+
+    assertThat(resolvedImportInfo.imports).hasSize(1);
+    CodeOwnerConfigFileInfo nestedResolvedImportInfo =
+        Iterables.getOnlyElement(resolvedImportInfo.imports);
+    assertThat(nestedResolvedImportInfo.project)
+        .isEqualTo(keyOfNestedImportedCodeOwnerConfig1.project().get());
+    assertThat(nestedResolvedImportInfo.branch)
+        .isEqualTo(keyOfNestedImportedCodeOwnerConfig1.branchNameKey().branch());
+    assertThat(nestedResolvedImportInfo.path)
+        .isEqualTo(
+            codeOwnerConfigOperations
+                .codeOwnerConfig(keyOfNestedImportedCodeOwnerConfig1)
+                .getFilePath());
+    assertThat(nestedResolvedImportInfo.importMode)
+        .isEqualTo(nestedCodeOwnerConfigReference1.importMode());
+    assertThat(nestedResolvedImportInfo.imports).isNull();
+    assertThat(nestedResolvedImportInfo.unresolvedImports).isNull();
+    assertThat(nestedResolvedImportInfo.unresolvedErrorMessage).isNull();
+
+    assertThat(resolvedImportInfo.unresolvedImports).hasSize(1);
+    CodeOwnerConfigFileInfo nestedUnresolvedImportInfo1 =
+        Iterables.getOnlyElement(resolvedImportInfo.unresolvedImports);
+    assertThat(nestedUnresolvedImportInfo1.project)
+        .isEqualTo(keyOfNestedImportedCodeOwnerConfig2.project().get());
+    assertThat(nestedUnresolvedImportInfo1.branch)
+        .isEqualTo(keyOfNestedImportedCodeOwnerConfig2.branchNameKey().branch());
+    assertThat(nestedUnresolvedImportInfo1.path)
+        .isEqualTo(
+            codeOwnerConfigOperations
+                .codeOwnerConfig(keyOfNestedImportedCodeOwnerConfig2)
+                .getFilePath());
+    assertThat(nestedUnresolvedImportInfo1.importMode)
+        .isEqualTo(nestedCodeOwnerConfigReference2.importMode());
+    assertThat(nestedUnresolvedImportInfo1.imports).isNull();
+    assertThat(nestedUnresolvedImportInfo1.unresolvedImports).isNull();
+    assertThat(nestedUnresolvedImportInfo1.unresolvedErrorMessage).isEqualTo("error message");
+  }
+
+  @Test
+  public void formatWithCyclicImports() throws Exception {
+    CodeOwnerConfig.Key codeOwnerConfigKey1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/bar/")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference1 =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, codeOwnerConfigKey1);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/bar/baz")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+    CodeOwnerConfigReference codeOwnerConfigReference2 =
+        createCodeOwnerConfigReference(CodeOwnerConfigImportMode.ALL, codeOwnerConfigKey2);
+
+    CodeOwnerConfigFileInfo codeOwnerConfigFileInfo =
+        CodeOwnerConfigFileJson.format(
+            codeOwnerConfigKey1,
+            /* resolvedImports= */ ImmutableList.of(
+                CodeOwnerConfigImport.createResolvedImport(
+                    codeOwnerConfigKey1, codeOwnerConfigKey2, codeOwnerConfigReference1),
+                CodeOwnerConfigImport.createResolvedImport(
+                    codeOwnerConfigKey2, codeOwnerConfigKey1, codeOwnerConfigReference2)),
+            /* unresolvedImports= */ ImmutableList.of());
+
+    assertThat(codeOwnerConfigFileInfo.project).isEqualTo(codeOwnerConfigKey1.project().get());
+    assertThat(codeOwnerConfigFileInfo.branch)
+        .isEqualTo(codeOwnerConfigKey1.branchNameKey().branch());
+    assertThat(codeOwnerConfigFileInfo.path)
+        .isEqualTo(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey1).getFilePath());
+    assertThat(codeOwnerConfigFileInfo.importMode).isNull();
+    assertThat(codeOwnerConfigFileInfo.unresolvedImports).isNull();
+    assertThat(codeOwnerConfigFileInfo.unresolvedErrorMessage).isNull();
+
+    assertThat(codeOwnerConfigFileInfo.imports).hasSize(1);
+    CodeOwnerConfigFileInfo resolvedImportInfo =
+        Iterables.getOnlyElement(codeOwnerConfigFileInfo.imports);
+    assertThat(resolvedImportInfo.project).isEqualTo(codeOwnerConfigKey2.project().get());
+    assertThat(resolvedImportInfo.branch).isEqualTo(codeOwnerConfigKey2.branchNameKey().branch());
+    assertThat(resolvedImportInfo.path)
+        .isEqualTo(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey2).getFilePath());
+    assertThat(resolvedImportInfo.importMode).isEqualTo(codeOwnerConfigReference1.importMode());
+    assertThat(resolvedImportInfo.unresolvedErrorMessage).isNull();
+
+    assertThat(resolvedImportInfo.imports).hasSize(1);
+    CodeOwnerConfigFileInfo nestedResolvedImportInfo =
+        Iterables.getOnlyElement(resolvedImportInfo.imports);
+    assertThat(nestedResolvedImportInfo.project).isEqualTo(codeOwnerConfigKey1.project().get());
+    assertThat(nestedResolvedImportInfo.branch)
+        .isEqualTo(codeOwnerConfigKey1.branchNameKey().branch());
+    assertThat(nestedResolvedImportInfo.path)
+        .isEqualTo(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey1).getFilePath());
+    assertThat(nestedResolvedImportInfo.importMode)
+        .isEqualTo(codeOwnerConfigReference1.importMode());
+    assertThat(nestedResolvedImportInfo.imports).isNull();
+    assertThat(nestedResolvedImportInfo.unresolvedImports).isNull();
+    assertThat(nestedResolvedImportInfo.unresolvedErrorMessage).isNull();
+  }
+}
diff --git a/resources/Documentation/config.md b/resources/Documentation/config.md
index 80c5b59..63feaf7 100644
--- a/resources/Documentation/config.md
+++ b/resources/Documentation/config.md
@@ -695,6 +695,22 @@
         in `@PLUGIN@.config`.\
         By default `true`.
 
+<a id="pluginCodeOwnersEnableAsyncMessageOnCodeOwnerApproval">plugin.@PLUGIN@.enableAsyncMessageOnCodeOwnerApproval</a>
+:       When a code owner approval is applied on a change the @PLUGIN@ plugin
+        posts a change message that lists the paths that are owned by the code
+        owner to inform that these paths are code owner approved now. This
+        setting controls whether this change message should be posted
+        asynchronously instead of synchronously extending the change message
+        that is posted for the approval by Gerrit core.\
+        Posting these change messages asynchronously improves the latency for
+        post review, since it does not need to wait until the owned paths are
+        computed for the code owner (computing the owned path for a user is
+        rather expensive).\
+        Can be overridden per project by setting
+        [codeOwners.enableAsyncMessageOnCodeOwnerApproval](#codeOwnersEnableAsyncMessageOnCodeOwnerApproval)
+        in `@PLUGIN@.config`.\
+        By default `true`.
+
 <a id="pluginCodeOwnersMaxCodeOwnerConfigCacheSize">plugin.@PLUGIN@.maxCodeOwnerConfigCacheSize</a>
 :       When computing code owner file statuses for a change (e.g. to compute
         the results for the code owners submit rule) parsed code owner config
@@ -1277,6 +1293,25 @@
         [plugin.@PLUGIN@.enableAsyncMessageOnAddReviewer](#pluginCodeOwnersEnableAsyncMessageOnAddReviewer)
         in `gerrit.config` is used.
 
+<a id="codeOwnersEnableAsyncMessageOnCodeOwnerApproval">codeOwners.enableAsyncMessageOnCodeOwnerApproval</a>
+:       When a code owner approval is applied on a change the @PLUGIN@ plugin
+        posts a change message that lists the paths that are owned by the code
+        owner to inform that these paths are code owner approved now. This
+        setting controls whether this change message should be posted
+        asynchronously instead of synchronously extending the change message
+        that is posted for the approval by Gerrit core.\
+        Posting these change messages asynchronously improves the latency for
+        post review, since it does not need to wait until the owned paths are
+        computed for the code owner (computing the owned path for a user is
+        rather expensive).\
+        Overrides the global setting
+        [plugin.@PLUGIN@.enableAsyncMessageOnCodeOwnerApproval](#pluginCodeOwnersEnableAsyncMessageOnOwnerApproval)
+        in `gerrit.config` and the `codeOwners.enableAsyncMessageOnCodeOwnerApproval`
+        setting from parent projects.\
+        If not set, the global setting
+        [plugin.@PLUGIN@.enableAsyncMessageOnCodeOwnerApproval](#pluginCodeOwnersEnableAsyncMessageOnCodeOwnerApproval)
+        in `gerrit.config` is used.
+
 ---
 
 Back to [@PLUGIN@ documentation index](index.html)
diff --git a/resources/Documentation/metrics.md b/resources/Documentation/metrics.md
index b5f7765..5a54abb 100644
--- a/resources/Documentation/metrics.md
+++ b/resources/Documentation/metrics.md
@@ -12,6 +12,9 @@
   added as a reviewer.
 ** `post_type':
    Whether the change message was posted synchronously or asynchronously.
+* `add_change_message_on_code_owner_approval`:
+  Latency for asynchronously adding a change message with the owned path when
+  a code owner approval is applied.
 * `compute_file_status`:
   Latency for computing the file status for one file.
 * `compute_file_statuses`:
@@ -25,8 +28,8 @@
 * `compute_patch_set_approvals`:
   Latency for computing the approvals of the current patch set.
 * `extend_change_message_on_post_review`:
-  Latency for extending the change message with the owned path when a code owner
-  approval is applied.
+  Latency for synchronously extending the change message with the owned path
+  when a code owner approval is applied.
 * `get_changed_files`:
   Latency for getting changed files from diff cache.
 * `prepare_file_status_computation`:
diff --git a/resources/Documentation/rest-api.md b/resources/Documentation/rest-api.md
index 0185851..c97ba2c 100644
--- a/resources/Documentation/rest-api.md
+++ b/resources/Documentation/rest-api.md
@@ -475,6 +475,71 @@
 (visible) users is returned, as many as are needed to fill up the requested
 limit.
 
+#### Request
+
+```
+  GET /projects/foo%2Fbar/branches/master/code_owners/docs%2Findex.md HTTP/1.0
+```
+
+#### Response
+
+```
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "code_owners": [
+      {
+        "account": {
+          "_account_id": 1000096
+        }
+      },
+      {
+        "account": {
+          "_account_id": 1001439
+        }
+      },
+      {
+        "account": {
+          "_account_id": 1007265
+        }
+      },
+      {
+        "account": {
+          "_account_id": 1009877
+        }
+      },
+      {
+        "account": {
+          "_account_id": 1002930
+        }
+      }
+    ],
+    "code_owner_configs": [
+      {
+        "project": "foo/bar",
+        "branch": "master",
+        "path": "/docs/OWNERS"
+      },
+      {
+        "project": "foo/bar",
+        "branch": "master",
+        "path": "/OWNERS",
+        "imports": [
+          {
+            "project": "foo",
+            "branch": "master",
+            "path": "/OWNERS",
+            "import_mode": "ALL",
+          }
+        ]
+      }
+    ]
+  }
+```
+
 #### <a id="scoringFactors">Scoring Factors
 
 The following factors are taken into account for computing the scores of the
@@ -550,36 +615,6 @@
 
 The same applies for [default code owners](config-guide.html#codeOwners).
 
-#### Request
-
-```
-  GET /projects/foo%2Fbar/branches/master/code_owners/docs%2Findex.md HTTP/1.0
-```
-
-#### Response
-
-```
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  {
-    "code_owners": [
-      {
-        "account": {
-          "_account_id": 1000096
-        }
-      },
-      {
-        "account": {
-          "_account_id": 1001439
-        },
-      }
-    ]
-  }
-```
-
 #### <a id="batch-list-code-owners"> Batch Request
 
 There is no REST endpoint that allows to retrieve code owners for multiple
@@ -918,6 +953,22 @@
 
 ---
 
+### <a id="code-owner-config-file-info"> CodeOwnerConfigFileInfo
+The `CodeOwnerConfigFileInfo` entity contains information about a code owner
+config file and its imports.
+
+| Field Name |          | Description |
+| ---------- | -------- | ----------- |
+| `project`  || The name of the project from which the code owner config was loaded, or for unresolved imports, from which the code owner config was supposed to be loaded.
+| `branch`   || The name of the branch from which the code owner config was loaded, or for unresolved imports, from which the code owner config was supposed to be loaded.
+| `path`     || The absolute path of the code owner config file.
+| `imports`  | optional | Imported code owner config files as [CodeOwnerConfigFileInfo](#code-owner-config-file-info) entities.
+| `unresolved_imports` | optional | Imported code owner config files that couldn't be resolved as [CodeOwnerConfigFileInfo](#code-owner-config-file-info) entities.
+| `unresolved_error_message` | optional | Message explaining why this code owner config couldn't be resolved. Only set if the `CodeOwnerConfigFileInfo` represents an import code owner config file that couldn't be resolved.
+| `import_mode` | optional | The import mode (`ALL` or `GLOBAL_CODE_OWNER_SETS_ONLY`). Only set if the `CodeOwnerConfigFileInfo` represents an imported code owner config file.
+
+---
+
 ### <a id="code-owner-config-info"> CodeOwnerConfigInfo
 The `CodeOwnerConfigInfo` entity contains information about a code owner config
 for a path.
@@ -1042,6 +1093,7 @@
 | ------------- | -------- | ----------- |
 | `code_owners` |          | List of code owners as [CodeOwnerInfo](#code-owner-info) entities. The code owners are sorted by a score that is computed from mutliple [scoring factors](#scoringFactors).
 | `owned_by_all_users` | optional | Whether the path is owned by all users. Not set if `false`.
+| `code_owner_configs` || The code owner config files that have been inspected to gather the code owners as [CodeOwnerConfigFileInfo](#code-owner-config-file-info) entities.
 | `debug_logs`  | optional | Debug logs that may help to understand why a user is or isn't suggested as a code owner. Only set if requested via `--debug`. This information is purely for debugging and the output may be changed at any time. This means bot callers must not parse the debug logs.
 
 ### <a id="file-code-owner-status-info"> FileCodeOwnerStatusInfo
diff --git a/web/code-owners-api.ts b/web/code-owners-api.ts
index 8f5ace1..edad393 100644
--- a/web/code-owners-api.ts
+++ b/web/code-owners-api.ts
@@ -21,7 +21,6 @@
   AccountInfo,
   BranchName,
   ChangeInfo,
-  NumericChangeId,
   RepoName,
 } from '@gerritcodereview/typescript-api/rest-api';
 
@@ -150,6 +149,10 @@
   owned_changed_files?: Array<OwnedChangedFileInfo>;
 }
 
+function changeBaseURL(change: ChangeInfo): string {
+  return `/changes/${encodeURIComponent(change.project)}~${change._number}`;
+}
+
 /**
  * Responsible for communicating with the rest-api
  *
@@ -186,9 +189,9 @@
    * @doc
    * https://gerrit.googlesource.com/plugins/code-owners/+/HEAD/resources/Documentation/rest-api.md#change-endpoints
    */
-  listOwnerStatus(changeId: NumericChangeId): Promise<CodeOwnerStatusInfo> {
+  listOwnerStatus(change: ChangeInfo): Promise<CodeOwnerStatusInfo> {
     return this.get(
-      `/changes/${changeId}/code_owners.status?limit=100000`
+      `${changeBaseURL(change)}/code_owners.status?limit=100000`
     ) as Promise<CodeOwnerStatusInfo>;
   }
 
@@ -196,12 +199,14 @@
    * Returns a promise fetching which files are owned by a given user as well
    * as which reviewers own which files.
    */
-  listOwnedPaths(changeId: NumericChangeId, account: AccountInfo) {
+  listOwnedPaths(change: ChangeInfo, account: AccountInfo) {
     if (!account.email && !account._account_id)
       return Promise.resolve(undefined);
     const user = account.email ?? account._account_id;
     return this.get(
-      `/changes/${changeId}/revisions/current/owned_paths?user=${user}&limit=10000&check_reviewers`
+      `${changeBaseURL(
+        change
+      )}/revisions/current/owned_paths?user=${user}&limit=10000&check_reviewers`
     ) as Promise<OwnedPathsInfo>;
   }
 
@@ -212,12 +217,12 @@
    * https://gerrit.googlesource.com/plugins/code-owners/+/HEAD/resources/Documentation/rest-api.md#list-code-owners-for-path-in-branch
    */
   listOwnersForPath(
-    changeId: NumericChangeId,
+    change: ChangeInfo,
     path: string,
     limit: number
   ): Promise<CodeOwnersInfo> {
     return this.get(
-      `/changes/${changeId}/revisions/current/code_owners` +
+      `${changeBaseURL(change)}/revisions/current/code_owners` +
         `/${encodeURIComponent(path)}?limit=${limit}&o=DETAILS`
     ) as Promise<CodeOwnersInfo>;
   }
@@ -247,7 +252,7 @@
       const config = (await this.get(
         `/projects/${encodeURIComponent(project)}/` +
           `branches/${encodeURIComponent(branch)}/` +
-          `code_owners.branch_config`
+          'code_owners.branch_config'
       )) as CodeOwnerBranchConfigInfo;
       return config;
     } catch (err) {
@@ -313,7 +318,7 @@
 
   listOwnerStatus(): Promise<CodeOwnerStatusInfo> {
     return this.fetchOnce('listOwnerStatus', () =>
-      this.codeOwnerApi.listOwnerStatus(this.change._number)
+      this.codeOwnerApi.listOwnerStatus(this.change)
     ) as Promise<CodeOwnerStatusInfo>;
   }
 
@@ -321,7 +326,7 @@
     const account = await this.getAccount();
     if (!account) return undefined;
     return this.fetchOnce('listOwnedPaths', () =>
-      this.codeOwnerApi.listOwnedPaths(this.change._number, account)
+      this.codeOwnerApi.listOwnedPaths(this.change, account)
     ) as Promise<OwnedPathsInfo | undefined>;
   }
 
diff --git a/web/code-owners-fetcher.ts b/web/code-owners-fetcher.ts
index 2057641..33fa3fa 100644
--- a/web/code-owners-fetcher.ts
+++ b/web/code-owners-fetcher.ts
@@ -14,11 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import {
-  ChangeInfo,
-  NumericChangeId,
-} from '@gerritcodereview/typescript-api/rest-api.js';
+import {ChangeInfo} from '@gerritcodereview/typescript-api/rest-api.js';
 import {RestPluginApi} from '@gerritcodereview/typescript-api/rest.js';
 import {CodeOwnersApi, FetchedOwner, OwnerStatus} from './code-owners-api.js';
 import {FileStatus} from './code-owners-model.js';
@@ -59,7 +55,7 @@
    */
   constructor(
     private readonly codeOwnerApi: CodeOwnersApi,
-    private readonly changeId: NumericChangeId,
+    private readonly change: ChangeInfo,
     private readonly filesToFetch: Array<string>,
     private readonly ownersLimit: number,
     maxConcurrentRequest: number
@@ -76,7 +72,7 @@
       try {
         this.fetchedOwners.set(filePath, {
           owners: await this.codeOwnerApi.listOwnersForPath(
-            this.changeId,
+            this.change,
             filePath,
             this.ownersLimit
           ),
@@ -178,7 +174,7 @@
     this.totalFetchCount = filesToFetch.length;
     this.ownersFetcher = new OwnersFetcher(
       this.codeOwnerApi,
-      this.change._number,
+      this.change,
       filesToFetch,
       this.options.ownersLimit,
       this.options.maxConcurrentRequests
diff --git a/web/code-owners-model.ts b/web/code-owners-model.ts
index 38d0f78..61fba03 100644
--- a/web/code-owners-model.ts
+++ b/web/code-owners-model.ts
@@ -87,6 +87,7 @@
   codeOwnerStatusMap: Map<string, FileStatus>;
   rawStatuses: Array<FileCodeOwnerStatusInfo>;
   newerPatchsetUploaded: boolean;
+  accounts?: {[account_id: number]: AccountInfo};
 }
 
 export interface FileStatus {
@@ -94,6 +95,7 @@
   status: OwnerStatus;
   newPath?: string | null;
   oldPath?: string | null;
+  reasons?: Array<string>;
 }
 
 export const BestSuggestionsLimit = 5;
diff --git a/web/code-owners-service.ts b/web/code-owners-service.ts
index 17b16ba..5f44337 100644
--- a/web/code-owners-service.ts
+++ b/web/code-owners-service.ts
@@ -205,6 +205,7 @@
       newerPatchsetUploaded: this.isOnNewerPatchset(
         ownerStatus.patch_set_number
       ),
+      accounts: ownerStatus.accounts,
     };
   }
 
@@ -288,6 +289,7 @@
           changeType: cur.change_type,
           status: oldPathStatus.status,
           newPath: newPathStatus ? newPathStatus.path : null,
+          reasons: oldPathStatus.reasons,
         });
       }
       if (newPathStatus) {
@@ -295,6 +297,7 @@
           changeType: cur.change_type,
           status: newPathStatus.status,
           oldPath: oldPathStatus ? oldPathStatus.path : null,
+          reasons: newPathStatus.reasons,
         });
       }
       return prev;
@@ -333,12 +336,18 @@
   private isOnNewerPatchset(patchsetId: number) {
     if (this.change.current_revision === undefined) return false;
     const latestRevision = this.change.revisions![this.change.current_revision];
+    if (latestRevision._number === 'edit') {
+      return false;
+    }
     return patchsetId > latestRevision._number;
   }
 
   private isOnOlderPatchset(patchsetId: number) {
     if (this.change.current_revision === undefined) return false;
     const latestRevision = this.change.revisions![this.change.current_revision];
+    if (latestRevision._number === 'edit') {
+      return false;
+    }
     return patchsetId < latestRevision._number;
   }
 
diff --git a/web/code-owners-service_test.ts b/web/code-owners-service_test.ts
index e9abbb1..f547a8e 100644
--- a/web/code-owners-service_test.ts
+++ b/web/code-owners-service_test.ts
@@ -119,7 +119,7 @@
       getApiStub
         .withArgs(
           sinon.match.any,
-          `/changes/${fakeChange._number}/code_owners.status?limit=100000`,
+          `/changes/${fakeChange.project}~${fakeChange._number}/code_owners.status?limit=100000`,
           sinon.match.any,
           sinon.match.any
         )
@@ -148,7 +148,7 @@
       assert.isTrue(getApiStub.called);
       assert.equal(
         getApiStub.lastCall.args[1],
-        `/changes/${fakeChange._number}/code_owners.status?limit=100000`
+        `/changes/${fakeChange.project}~${fakeChange._number}/code_owners.status?limit=100000`
       );
     });
 
@@ -212,7 +212,7 @@
       getApiStub
         .withArgs(
           sinon.match.any,
-          `/changes/${fakeChange._number}/code_owners.status?limit=100000`,
+          `/changes/${fakeChange.project}~${fakeChange._number}/code_owners.status?limit=100000`,
           sinon.match.any,
           sinon.match.any
         )
diff --git a/web/owner-status-column.ts b/web/owner-status-column.ts
index b8006e5..9b0cb86 100644
--- a/web/owner-status-column.ts
+++ b/web/owner-status-column.ts
@@ -32,6 +32,8 @@
   basePatchNum: BasePatchSetNum;
 }
 
+const ACCOUNT_TEMPLATE_REGEX = '<GERRIT_ACCOUNT_(\\d+)>';
+
 const MAGIC_FILES = ['/COMMIT_MSG', '/MERGE_LIST', '/PATCHSET_LEVEL'];
 const STATUS_CODE = {
   NO_STATUS: 'no-status',
@@ -199,6 +201,9 @@
   @property({type: String, reflect: true, attribute: 'owner-status'})
   ownerStatus?: string;
 
+  @property({type: Array})
+  ownerReasons?: Array<string>;
+
   static override get styles() {
     return [
       css`
@@ -251,11 +256,28 @@
     this.computeStatus();
   }
 
+  private renderReason(reason: string): string {
+    let reasonWithAccounts = reason.replace(
+      new RegExp(ACCOUNT_TEMPLATE_REGEX, 'g'),
+      (_accountIdTemplate, accountId) => {
+        const parsedAccountId = Number(accountId);
+        const accountInText = (this.status?.accounts || {})[parsedAccountId];
+        if (!accountInText) {
+          return `Gerrit Account ${parsedAccountId}`;
+        }
+        return accountInText.display_name ?? accountInText.name ?? '';
+      }
+    );
+    return (
+      reasonWithAccounts.charAt(0).toUpperCase() + reasonWithAccounts.slice(1)
+    );
+  }
+
   override render() {
     if (
       this.computeHidden() ||
       this.status === undefined ||
-      !this.path || 
+      !this.path ||
       MAGIC_FILES.includes(this.path)
     )
       return nothing;
@@ -263,7 +285,10 @@
   }
 
   private renderStatus() {
-    const info = STATUS_TOOLTIP[this.ownerStatus!];
+    let info = STATUS_TOOLTIP[this.ownerStatus!];
+    if (this.ownerReasons) {
+      info = this.ownerReasons.map(r => this.renderReason(r)).join('\n');
+    }
     const summary = STATUS_SUMMARY[this.ownerStatus!];
     const icon = STATUS_ICON[this.ownerStatus!];
     return html`
@@ -303,6 +328,7 @@
       return html` <gr-avatar-stack
           .accounts=${allOwners.slice(0, 3)}
           .forceFetch=${true}
+          .enableHover=${true}
         >
           <gr-tooltip-content
             slot="fallback"
@@ -358,23 +384,32 @@
 
     const statuses = (paths ?? [])
       .filter(path => !MAGIC_FILES.includes(path))
-      .map(path => this.extractStatus(codeOwnerStatusMap.get(path)!, false));
+      .map(path => ({
+        status: this.extractStatus(codeOwnerStatusMap.get(path), false),
+        reasons: codeOwnerStatusMap.get(path)?.reasons,
+      }));
     // oldPath may contain null, so filter that as well.
     const oldStatuses = (oldPaths ?? [])
       .filter(path => !MAGIC_FILES.includes(path) && !!path)
-      .map(path => this.extractStatus(codeOwnerStatusMap.get(path)!, true));
+      .map(path => ({
+        status: this.extractStatus(codeOwnerStatusMap.get(path), true),
+        reasons: codeOwnerStatusMap.get(path)?.reasons,
+      }));
     const allStatuses = statuses.concat(oldStatuses);
     if (allStatuses.length === 0) {
       return;
     }
-    this.ownerStatus = allStatuses.reduce((a, b) =>
-      STATUS_PRIORITY_ORDER.indexOf(a) < STATUS_PRIORITY_ORDER.indexOf(b)
+    const computedStatus = allStatuses.reduce((a, b) =>
+      STATUS_PRIORITY_ORDER.indexOf(a.status) <
+      STATUS_PRIORITY_ORDER.indexOf(b.status)
         ? a
         : b
     );
+    this.ownerStatus = computedStatus.status;
+    this.ownerReasons = computedStatus.reasons;
   }
 
-  private extractStatus(statusItem: FileStatus, oldPath: boolean) {
+  private extractStatus(statusItem: FileStatus | undefined, oldPath: boolean) {
     if (statusItem === undefined) {
       return STATUS_CODE.NO_STATUS;
     } else if (statusItem.status === OwnerStatus.INSUFFICIENT_REVIEWERS) {
diff --git a/web/suggest-owners-trigger.ts b/web/suggest-owners-trigger.ts
index f0cf9c9..fb2ca98 100644
--- a/web/suggest-owners-trigger.ts
+++ b/web/suggest-owners-trigger.ts
@@ -56,13 +56,6 @@
       </gr-button>
       <span class="extras">
         <a
-          @click=${this.reportBugClick}
-          href="https://bugs.chromium.org/p/gerrit/issues/entry?template=code-owners-plugin"
-          target="_blank"
-        >
-          <gr-icon icon="bug_report" filled title="report a problem"></gr-icon>
-        </a>
-        <a
           @click=${this.reportDocClick}
           href="https://gerrit.googlesource.com/plugins/code-owners/+/HEAD/resources/Documentation/how-to-use.md"
           target="_blank"
@@ -96,8 +89,4 @@
   private reportDocClick() {
     this.reporting?.reportInteraction('code-owners-doc-click');
   }
-
-  private reportBugClick() {
-    this.reporting?.reportInteraction('code-owners-bug-click');
-  }
 }
diff --git a/web/suggest-owners.ts b/web/suggest-owners.ts
index fbda31c..4fe2a7d 100644
--- a/web/suggest-owners.ts
+++ b/web/suggest-owners.ts
@@ -362,7 +362,7 @@
     if (!isLoading) return nothing;
     return html`
       <p class="loading">
-        <span class="loadingSpin"></span>
+        <span class="loadingSpin"><gr-icon icon="sync"></gr-icon></span>
         ${this.selectedSuggestionsLoadProgress}
       </p>
     `;