Merge branch 'stable-3.14'

* stable-3.14:
  Improve documentation for auto-owners-approval
  Convert FilesOwnersResponse to a record
  Show auto-approved owner approvals in the UI
  Expose auto-approved files in the owners REST API
  Extract shared auto-owners-approved logic
  Swtich auto-owners-approved to a allowlist model
  Default auto-owners-approved to false

Change-Id: If71c81eed1a825138652aa72883d1080d5f80ada
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersMap.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersMap.java
index 102f954..d6dbc13 100644
--- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersMap.java
+++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersMap.java
@@ -31,7 +31,7 @@
   private Map<String, Set<Account.Id>> fileOwners = Maps.newHashMap();
   private Map<String, Set<Account.Id>> fileReviewers = Maps.newHashMap();
   private Map<String, Set<String>> fileGroupOwners = Maps.newHashMap();
-  private Set<String> fileOwnersBannedAutoApproval = Sets.newHashSet();
+  private Set<String> fileOwnersAllowedAutoApproval = Sets.newHashSet();
   private Optional<LabelDefinition> label = Optional.empty();
 
   @Override
@@ -87,8 +87,8 @@
     return fileGroupOwners;
   }
 
-  public Set<String> getFileOwnersBannedAutoApproval() {
-    return fileOwnersBannedAutoApproval;
+  public Set<String> getFileOwnersAllowedAutoApproval() {
+    return fileOwnersAllowedAutoApproval;
   }
 
   public void addFileOwners(String file, Set<Id> owners) {
@@ -127,12 +127,12 @@
     fileGroupOwners.computeIfAbsent(file, (f) -> Sets.newHashSet()).addAll(groupOwners);
   }
 
-  public void banFileFromOwnersAutoApproval(String file) {
-    fileOwnersBannedAutoApproval.add(file);
+  public void addAllowedFileForOwnersAutoApproval(String file) {
+    fileOwnersAllowedAutoApproval.add(file);
   }
 
-  public void allowFileForAutoApproval(String file) {
-    fileOwnersBannedAutoApproval.remove(file);
+  public void removeAllowedFileForOwnersAutoApproval(String file) {
+    fileOwnersAllowedAutoApproval.remove(file);
   }
 
   public Optional<LabelDefinition> getLabel() {
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwners.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwners.java
index c7d33fe..71ce117 100644
--- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwners.java
+++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwners.java
@@ -91,7 +91,7 @@
 
   private final Map<String, Set<String>> fileGroupOwners;
 
-  private final Set<String> fileOwnersBannedAutoApproval;
+  private final Set<String> fileOwnersAllowedAutoApproval;
 
   private final boolean expandGroups;
 
@@ -178,7 +178,7 @@
     matchers = map.getMatchers();
     fileOwners = map.getFileOwners();
     fileGroupOwners = map.getFileGroupOwners();
-    fileOwnersBannedAutoApproval = map.getFileOwnersBannedAutoApproval();
+    fileOwnersAllowedAutoApproval = map.getFileOwnersAllowedAutoApproval();
     label = globalLabel.or(map::getLabel);
   }
 
@@ -212,8 +212,8 @@
     return fileGroupOwners;
   }
 
-  public Set<String> getFileOwnersBannedAutoApproval() {
-    return fileOwnersBannedAutoApproval;
+  public Set<String> getFileOwnersAllowedAutoApproval() {
+    return fileOwnersAllowedAutoApproval;
   }
 
   public boolean expandGroups() {
@@ -265,10 +265,9 @@
         ownersMap.addFileOwners(path, currentEntry.getOwners());
         ownersMap.addFileReviewers(path, currentEntry.getReviewers());
         ownersMap.addFileGroupOwners(path, currentEntry.getGroupOwners());
-        if (!currentEntry.isAutoOwnersApproved()) {
-          ownersMap.banFileFromOwnersAutoApproval(path);
+        if (currentEntry.isAutoOwnersApproved()) {
+          ownersMap.addAllowedFileForOwnersAutoApproval(path);
         }
-
         // Only add the path to the OWNERS file to reduce the number of
         // entries in the result
         if (currentEntry.getOwnersPath() != null) {
@@ -392,18 +391,17 @@
         ownersMap.addFileGroupOwners(path, matcher.getGroupOwners());
         ownersMap.addFileReviewers(path, matcher.getReviewers());
         switch (matcher.getAutoOwnersApproved()) {
-          // We have an explicit allowance for this matcher
-          // Make sure that anything added at OWNERS level is removed
+          // We have an explicit allowance for this matcher.
           case InheritableBoolean.TRUE:
-            ownersMap.allowFileForAutoApproval(path);
+            ownersMap.addAllowedFileForOwnersAutoApproval(path);
             break;
-          // We have an explicit ban for this matcher
+          // We have an explicit disable for this matcher.
           case InheritableBoolean.FALSE:
-            ownersMap.banFileFromOwnersAutoApproval(path);
+            ownersMap.removeAllowedFileForOwnersAutoApproval(path);
             break;
 
-          // There is no matcher-level specification of auto-owner-approved
-          // therefore the global OWNER-level still applies
+          // There is no matcher-level specification of auto-owner-approved,
+          // therefore the OWNERS-level resolution still applies.
           default:
             break;
         }
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntry.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntry.java
index 7b42709..8402642 100644
--- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntry.java
+++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntry.java
@@ -79,8 +79,8 @@
       this.label = config.getLabel().or(() -> inheritedLabel);
     } else {
       this.label = config.getLabel();
-      // Default to true unless the OWNERS file explicitly sets it to false.
-      this.setAutoOwnersApproved(config.getAutoOwnersApproved() != InheritableBoolean.FALSE);
+      // Default to false unless the OWNERS file explicitly sets it to true.
+      this.setAutoOwnersApproved(config.getAutoOwnersApproved() == InheritableBoolean.TRUE);
     }
   }
 
@@ -129,7 +129,7 @@
   protected String ownersPath;
   protected Map<String, Matcher> matchers = Maps.newHashMap();
   protected Set<String> groupOwners = Sets.newHashSet();
-  protected boolean autoOwnersApproved = true;
+  protected boolean autoOwnersApproved;
   protected boolean explicitAutoOwnersApproved;
 
   protected ReadOnlyPathOwnersEntry(boolean inherited) {
diff --git a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersTest.java b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersTest.java
index ca09d57..134eeda 100644
--- a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersTest.java
+++ b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersTest.java
@@ -512,7 +512,7 @@
             CACHE_MOCK,
             Optional.empty());
 
-    assertThat(owners.getFileOwnersBannedAutoApproval()).isEmpty();
+    assertThat(owners.getFileOwnersAllowedAutoApproval()).isEmpty();
     assertThat(owners.getFileOwners()).isEmpty();
   }
 
@@ -520,7 +520,7 @@
   public void testAutoOwnersApprovedInheritedFromRoot() throws Exception {
     expectConfig(
         "OWNERS",
-        "inherited: true\nauto-owners-approved: false\nowners:\n- " + USER_A_EMAIL_COM + "\n");
+        "inherited: true\nauto-owners-approved: true\nowners:\n- " + USER_A_EMAIL_COM + "\n");
     expectConfig("dir/OWNERS", "inherited: true\nowners:\n- " + USER_B_EMAIL_COM + "\n");
 
     replayAll();
@@ -538,14 +538,14 @@
             CACHE_MOCK,
             Optional.empty());
 
-    assertThat(owners.getFileOwnersBannedAutoApproval()).contains("dir/file.txt");
+    assertThat(owners.getFileOwnersAllowedAutoApproval()).contains("dir/file.txt");
   }
 
   @Test
   public void testAutoOwnersApprovedDefaultsWhenInheritanceStopped() throws Exception {
     expectConfig(
         "OWNERS",
-        "inherited: true\nauto-owners-approved: false\nowners:\n- " + USER_A_EMAIL_COM + "\n");
+        "inherited: true\nauto-owners-approved: true\nowners:\n- " + USER_A_EMAIL_COM + "\n");
     expectConfig("dir/OWNERS", "inherited: false\nowners:\n- " + USER_B_EMAIL_COM + "\n");
 
     replayAll();
@@ -563,7 +563,7 @@
             CACHE_MOCK,
             Optional.empty());
 
-    assertThat(owners.getFileOwnersBannedAutoApproval()).isEmpty();
+    assertThat(owners.getFileOwnersAllowedAutoApproval()).isEmpty();
   }
 
   @Test
@@ -574,7 +574,7 @@
         "OWNERS",
         RefNames.REFS_CONFIG,
         parentRepository1,
-        "inherited: true\nauto-owners-approved: false\nowners:\n- " + USER_A_EMAIL_COM + "\n");
+        "inherited: true\nauto-owners-approved: true\nowners:\n- " + USER_A_EMAIL_COM + "\n");
 
     mockParentRepository(parentRepository1NameKey, parentRepository1);
     replayAll();
@@ -592,7 +592,7 @@
             CACHE_MOCK,
             Optional.empty());
 
-    assertThat(owners.getFileOwnersBannedAutoApproval()).contains("file.txt");
+    assertThat(owners.getFileOwnersAllowedAutoApproval()).contains("file.txt");
   }
 
   @Test
@@ -622,7 +622,7 @@
             CACHE_MOCK,
             Optional.empty());
 
-    assertThat(owners.getFileOwnersBannedAutoApproval()).contains("file.txt");
+    assertThat(owners.getFileOwnersAllowedAutoApproval()).isEmpty();
   }
 
   private void mockOwners(String... owners) throws IOException {
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/AlreadyApprovedByPredicate.java b/owners/src/main/java/com/googlesource/gerrit/owners/AlreadyApprovedByPredicate.java
index e74e402..eb3ca43 100644
--- a/owners/src/main/java/com/googlesource/gerrit/owners/AlreadyApprovedByPredicate.java
+++ b/owners/src/main/java/com/googlesource/gerrit/owners/AlreadyApprovedByPredicate.java
@@ -18,10 +18,12 @@
 import static com.google.common.flogger.LazyArgs.lazy;
 import static com.googlesource.gerrit.owners.AlreadyApprovedByOperand.FULL_OPERAND_WITH_PLUGIN_NAME;
 import static com.googlesource.gerrit.owners.AlreadyApprovedByOperand.OPERAND;
+import static com.googlesource.gerrit.owners.AutoOwnersApprovalFunctions.allowsAutoApprovalOnPatch;
+import static com.googlesource.gerrit.owners.AutoOwnersApprovalFunctions.modifiedFilesBetweenPatchSets;
+import static com.googlesource.gerrit.owners.AutoOwnersApprovalFunctions.touchedPaths;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
@@ -30,7 +32,6 @@
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import com.google.gerrit.server.query.approval.ApprovalContext;
@@ -39,10 +40,7 @@
 import com.googlesource.gerrit.owners.restapi.GetFilesOwners;
 import java.io.IOException;
 import java.util.Map;
-import java.util.Optional;
 import java.util.Set;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -56,8 +54,6 @@
   private final UserInPredicate.Field predicateField;
 
   private static final boolean DISABLE_RENAME_DETECTION = false;
-  private static final DiffOptions DO_NOT_IGNORE_REBASE =
-      DiffOptions.builder().skipFilesWithAllEditsDueToRebase(false).build();
 
   public AlreadyApprovedByPredicate(
       GetFilesOwners getFilesOwners,
@@ -94,38 +90,27 @@
           project);
 
       Map<String, FileDiffOutput> priorVsCurrent =
-          diffOperations
-              .listModifiedFiles(
-                  project,
-                  sourcePatchSet.commitId(),
-                  targetPatchSet.commitId(),
-                  DO_NOT_IGNORE_REBASE)
-              .entrySet()
-              .stream()
-              // COMMIT_MSG has never an owner, we don't ever want to consider it, even if it
-              // was modified as part of this patch-set.
-              .filter(entry -> !Patch.COMMIT_MSG.equals(entry.getKey()))
-              .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+          modifiedFilesBetweenPatchSets(diffOperations, project, sourcePatchSet, targetPatchSet);
 
       // We can't simply look at keys because it won't contain the old name of renamed-files.
-      Set<String> allFilePathsInDiff =
-          priorVsCurrent.values().stream()
-              .flatMap(v -> Stream.of(v.newPath(), v.oldPath()))
-              .filter(Optional::isPresent)
-              .map(Optional::get)
-              .collect(Collectors.toSet());
+      Set<String> allFilePathsInDiff = touchedPaths(priorVsCurrent);
 
       String branch = ctx.changeData().branchOrThrow().branch();
       Set<String> filesOwnedByApprover =
           getFilesOwners.filterFilesOwnedBy(currentApprover, allFilePathsInDiff, project, branch);
 
-      if (isApproverAlsoOwnerAndUploader(currentApprover, changeOwner, uploader)
-          && allTouchedFilesAreOwned(filesOwnedByApprover, allFilePathsInDiff)
-          && getFilesOwners.noOwnedFileIsBannedFromAutoApproval(
-              filesOwnedByApprover, project, branch)) {
+      if (allowsAutoApprovalOnPatch(
+          currentApprover,
+          changeOwner,
+          uploader,
+          filesOwnedByApprover,
+          allFilePathsInDiff,
+          getFilesOwners,
+          project,
+          branch)) {
         logger.atFinest().log(
             "Approver '%s' is change owner and uploader. only owned files have been modified and"
-                + " none of them has auto-owners-approved=false. Label WILL be copied.",
+                + " all of them allow auto-owners-approved. Label WILL be copied.",
             currentApprover);
         return true;
       }
@@ -219,17 +204,6 @@
     return !d.oldPath().equals(d.newPath());
   }
 
-  private static boolean isApproverAlsoOwnerAndUploader(
-      Account.Id currentApprover, Account.Id changeOwner, Account.Id uploader) {
-    return currentApprover.equals(changeOwner) && currentApprover.equals(uploader);
-  }
-
-  private static boolean allTouchedFilesAreOwned(
-      Set<String> filesOwnedByApprover, Set<String> allFilePathsInDiff) {
-    return !filesOwnedByApprover.isEmpty()
-        && filesOwnedByApprover.size() == allFilePathsInDiff.size();
-  }
-
   private int getParentNum(ObjectId objectId, RevWalk revWalk) {
     try {
       RevCommit commit = revWalk.parseCommit(objectId);
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/AutoOwnersApprovalFunctions.java b/owners/src/main/java/com/googlesource/gerrit/owners/AutoOwnersApprovalFunctions.java
new file mode 100644
index 0000000..7b5db51
--- /dev/null
+++ b/owners/src/main/java/com/googlesource/gerrit/owners/AutoOwnersApprovalFunctions.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.owners;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.googlesource.gerrit.owners.common.InvalidOwnersFileException;
+import com.googlesource.gerrit.owners.restapi.GetFilesOwners;
+import java.io.IOException;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public final class AutoOwnersApprovalFunctions {
+  private static final DiffOptions DO_NOT_IGNORE_REBASE =
+      DiffOptions.builder().skipFilesWithAllEditsDueToRebase(false).build();
+
+  public static Map<String, FileDiffOutput> modifiedFilesBetweenPatchSets(
+      DiffOperations diffOperations,
+      Project.NameKey project,
+      PatchSet sourcePatchSet,
+      PatchSet targetPatchSet)
+      throws DiffNotAvailableException {
+    return diffOperations
+        .listModifiedFiles(
+            project, sourcePatchSet.commitId(), targetPatchSet.commitId(), DO_NOT_IGNORE_REBASE)
+        .entrySet()
+        .stream()
+        // COMMIT_MSG has never an owner, we don't ever want to consider it, even if it
+        // was modified as part of this patch-set.
+        .filter(entry -> !Patch.COMMIT_MSG.equals(entry.getKey()))
+        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+  }
+
+  public static Set<String> touchedPaths(Map<String, FileDiffOutput> priorVsCurrent) {
+    return priorVsCurrent.values().stream()
+        .flatMap(v -> Stream.of(v.newPath(), v.oldPath()))
+        .filter(Optional::isPresent)
+        .map(Optional::get)
+        .collect(Collectors.toSet());
+  }
+
+  public static boolean allowsAutoApprovalOnPatch(
+      Account.Id approver,
+      Account.Id changeOwner,
+      Account.Id uploader,
+      Set<String> filesOwnedByApprover,
+      Set<String> allTouchedFiles,
+      GetFilesOwners getFilesOwners,
+      Project.NameKey project,
+      String branch)
+      throws IOException, InvalidOwnersFileException {
+    return approver.equals(changeOwner)
+        && approver.equals(uploader)
+        && !filesOwnedByApprover.isEmpty()
+        && filesOwnedByApprover.size() == allTouchedFiles.size()
+        && getFilesOwners.allOwnedFilesAllowAutoApproval(filesOwnedByApprover, project, branch);
+  }
+}
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/entities/FilesOwnersResponse.java b/owners/src/main/java/com/googlesource/gerrit/owners/entities/FilesOwnersResponse.java
index 3f6c937..819b741 100644
--- a/owners/src/main/java/com/googlesource/gerrit/owners/entities/FilesOwnersResponse.java
+++ b/owners/src/main/java/com/googlesource/gerrit/owners/entities/FilesOwnersResponse.java
@@ -15,50 +15,12 @@
 
 package com.googlesource.gerrit.owners.entities;
 
-import com.google.common.base.Objects;
 import java.util.Map;
 import java.util.Set;
 
 /* Files to Owners response API representation */
-public class FilesOwnersResponse {
-
-  public final Map<String, Set<GroupOwner>> files;
-  public final Map<Integer, Map<String, Integer>> ownersLabels;
-  public final Map<String, Set<GroupOwner>> filesApproved;
-
-  public FilesOwnersResponse(
-      Map<Integer, Map<String, Integer>> ownersLabels,
-      Map<String, Set<GroupOwner>> files,
-      Map<String, Set<GroupOwner>> filesApproved) {
-    this.ownersLabels = ownersLabels;
-    this.files = files;
-    this.filesApproved = filesApproved;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (this == o) return true;
-    if (o == null || getClass() != o.getClass()) return false;
-    FilesOwnersResponse that = (FilesOwnersResponse) o;
-    return Objects.equal(files, that.files)
-        && Objects.equal(ownersLabels, that.ownersLabels)
-        && Objects.equal(filesApproved, that.filesApproved);
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hashCode(files, ownersLabels, filesApproved);
-  }
-
-  @Override
-  public String toString() {
-    return "FilesOwnersResponse{"
-        + "files="
-        + files
-        + ", ownersLabels="
-        + ownersLabels
-        + ", filesApproved="
-        + filesApproved
-        + '}';
-  }
-}
+public record FilesOwnersResponse(
+    Map<Integer, Map<String, Integer>> ownersLabels,
+    Map<String, Set<GroupOwner>> files,
+    Map<String, Set<GroupOwner>> filesApproved,
+    Map<String, Set<GroupOwner>> filesAutoApproved) {}
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/restapi/GetFilesOwners.java b/owners/src/main/java/com/googlesource/gerrit/owners/restapi/GetFilesOwners.java
index af62387..c61c901 100644
--- a/owners/src/main/java/com/googlesource/gerrit/owners/restapi/GetFilesOwners.java
+++ b/owners/src/main/java/com/googlesource/gerrit/owners/restapi/GetFilesOwners.java
@@ -15,11 +15,18 @@
 
 package com.googlesource.gerrit.owners.restapi;
 
+import static com.googlesource.gerrit.owners.AutoOwnersApprovalFunctions.allowsAutoApprovalOnPatch;
+import static com.googlesource.gerrit.owners.AutoOwnersApprovalFunctions.modifiedFilesBetweenPatchSets;
+import static com.googlesource.gerrit.owners.AutoOwnersApprovalFunctions.touchedPaths;
+
 import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -34,6 +41,8 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
@@ -53,8 +62,10 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.function.Predicate;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.eclipse.jgit.lib.Repository;
@@ -65,6 +76,7 @@
   private final AccountCache accountCache;
   private final ProjectCache projectCache;
   private final GitRepositoryManager repositoryManager;
+  private final DiffOperations diffOperations;
   private final PluginSettings pluginSettings;
   private final GerritApi gerritApi;
   private final PathOwnersEntriesCache cache;
@@ -79,6 +91,7 @@
       AccountCache accountCache,
       ProjectCache projectCache,
       GitRepositoryManager repositoryManager,
+      DiffOperations diffOperations,
       PluginSettings pluginSettings,
       GerritApi gerritApi,
       PathOwnersEntriesCache cache) {
@@ -86,6 +99,7 @@
     this.accountCache = accountCache;
     this.projectCache = projectCache;
     this.repositoryManager = repositoryManager;
+    this.diffOperations = diffOperations;
     this.pluginSettings = pluginSettings;
     this.gerritApi = gerritApi;
     this.cache = cache;
@@ -108,13 +122,13 @@
         .collect(Collectors.toSet());
   }
 
-  public boolean noOwnedFileIsBannedFromAutoApproval(
+  public boolean allOwnedFilesAllowAutoApproval(
       Set<String> ownedPaths, Project.NameKey project, String branch)
       throws IOException, InvalidOwnersFileException {
     PathOwners owners = getPathOwners(project, branch, ownedPaths);
-    Set<String> filesBannedFromAutoOwnersApproval = owners.getFileOwnersBannedAutoApproval();
+    Set<String> filesAllowedForOwnersAutoApproval = owners.getFileOwnersAllowedAutoApproval();
 
-    return ownedPaths.stream().noneMatch(filesBannedFromAutoOwnersApproval::contains);
+    return filesAllowedForOwnersAutoApproval.containsAll(ownedPaths);
   }
 
   @Override
@@ -165,8 +179,29 @@
                   isApprovedByOwner(
                       fileExpandedOwners.get(fileOwnerEntry.getKey()), ownersLabels, label));
 
+      Map<String, Set<GroupOwner>> filesAutoApprovedByOwners;
+      Map<String, Set<GroupOwner>> filesExplicitlyApprovedByOwners;
+      Set<String> filesAllowedAutoApproval = owners.getFileOwnersAllowedAutoApproval();
+
+      if (Sets.intersection(filesAllowedAutoApproval, filesApprovedByOwners.keySet()).isEmpty()) {
+        filesAutoApprovedByOwners = Map.of();
+        filesExplicitlyApprovedByOwners = filesApprovedByOwners;
+      } else {
+        Set<String> filesAutoApproved =
+            getFilesAutoApproved(revision, changeData, filesApprovedByOwners);
+        filesAutoApprovedByOwners =
+            Maps.filterKeys(filesApprovedByOwners, filesAutoApproved::contains);
+        filesExplicitlyApprovedByOwners =
+            Maps.filterKeys(
+                filesApprovedByOwners, filePath -> !filesAutoApproved.contains(filePath));
+      }
+
       return Response.ok(
-          new FilesOwnersResponse(ownersLabels, filesWithPendingOwners, filesApprovedByOwners));
+          new FilesOwnersResponse(
+              ownersLabels,
+              filesWithPendingOwners,
+              filesExplicitlyApprovedByOwners,
+              filesAutoApprovedByOwners));
     } catch (InvalidOwnersFileException e) {
       logger.atSevere().withCause(e).log("Reading/parsing OWNERS file error.");
       throw new ResourceConflictException(e.getMessage(), e);
@@ -240,6 +275,98 @@
         .anyMatch(value -> value >= label.getScore());
   }
 
+  private Set<String> getFilesAutoApproved(
+      RevisionResource revision,
+      ChangeData changeData,
+      Map<String, Set<GroupOwner>> filesApprovedByOwners)
+      throws IOException, InvalidOwnersFileException, DiffNotAvailableException {
+    PatchSet sourcePatchSet = getPreviousPatchSet(revision);
+    if (sourcePatchSet == null) {
+      return Set.of();
+    }
+    Account.Id ownerId = revision.getChange().getOwner();
+    String branch = changeData.change().getDest().branch();
+    Project.NameKey project = changeData.project();
+
+    Set<String> allFilesTouchedInTheLastPatchSet =
+        touchedPaths(
+            modifiedFilesBetweenPatchSets(
+                diffOperations, project, sourcePatchSet, revision.getPatchSet()));
+
+    Map<Account.Id, List<PatchSetApproval>> approvalsByAccount =
+        changeData.currentApprovals().stream()
+            .collect(Collectors.groupingBy(PatchSetApproval::accountId));
+
+    List<PatchSetApproval> changeOwnerApprovals = approvalsByAccount.get(ownerId);
+
+    // If the change owner didn't approve the label or the label was not copied from the previous
+    // patch set then auto-owners-approvals cannot qualify for this patch-set.
+    if (changeOwnerApprovals == null
+        || changeOwnerApprovals.stream().anyMatch(isNotCopiedApproval())) {
+      return Set.of();
+    }
+
+    // Otherwise we check if the change owner was eligible for auto-owners-approved
+    Set<String> filesOwnedByChangeOwnerInTheLastPatchSet =
+        filterFilesOwnedBy(ownerId, allFilesTouchedInTheLastPatchSet, project, branch);
+
+    if (!allowsAutoApprovalOnPatch(
+        ownerId,
+        ownerId,
+        revision.getPatchSet().uploader(),
+        filesOwnedByChangeOwnerInTheLastPatchSet,
+        allFilesTouchedInTheLastPatchSet,
+        this,
+        project,
+        branch)) {
+      return Set.of();
+    }
+
+    return getAutoApprovedFiles(filesApprovedByOwners, approvalsByAccount);
+  }
+
+  private Set<String> getAutoApprovedFiles(
+      Map<String, Set<GroupOwner>> filesApprovedByOwners,
+      Map<Account.Id, List<PatchSetApproval>> currentApprovalsByAccount) {
+    return filesApprovedByOwners.keySet().stream()
+        .filter(notExplicitlyApprovedByAnOwner(filesApprovedByOwners, currentApprovalsByAccount))
+        .collect(Collectors.toSet());
+  }
+
+  private Predicate<String> notExplicitlyApprovedByAnOwner(
+      Map<String, Set<GroupOwner>> fileExpandedOwners,
+      Map<Account.Id, List<PatchSetApproval>> currentApprovalsByAccount) {
+    return filePath ->
+        ownerIds(fileExpandedOwners.get(filePath))
+            .map(currentApprovalsByAccount::get)
+            .filter(Objects::nonNull)
+            .flatMap(List::stream)
+            .noneMatch(isNotCopiedApproval());
+  }
+
+  private PatchSet getPreviousPatchSet(RevisionResource revision) {
+    int sourcePatchSetNumber = revision.getPatchSet().id().get() - 1;
+    if (sourcePatchSetNumber < 1) {
+      return null;
+    }
+
+    return revision
+        .getNotes()
+        .getPatchSets()
+        .get(PatchSet.id(revision.getChange().getId(), sourcePatchSetNumber));
+  }
+
+  private static Predicate<PatchSetApproval> isNotCopiedApproval() {
+    Predicate<PatchSetApproval> func = PatchSetApproval::copied;
+    return func.negate();
+  }
+
+  private Stream<Account.Id> ownerIds(Set<GroupOwner> fileOwners) {
+    return fileOwners.stream()
+        .filter(owner -> owner instanceof Owner)
+        .map(owner -> Account.id(((Owner) owner).getId()));
+  }
+
   private Stream<Integer> codeReviewLabelValue(
       Map<Integer, Map<String, Integer>> ownersLabels, int ownerId, String labelId) {
     return Stream.ofNullable(ownersLabels.get(ownerId))
diff --git a/owners/src/main/resources/Documentation/auto-owners-approval-images/example-1.1.png b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-1.1.png
new file mode 100644
index 0000000..bfa20f5
--- /dev/null
+++ b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-1.1.png
Binary files differ
diff --git a/owners/src/main/resources/Documentation/auto-owners-approval-images/example-1.2.png b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-1.2.png
new file mode 100644
index 0000000..d9aaa1d
--- /dev/null
+++ b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-1.2.png
Binary files differ
diff --git a/owners/src/main/resources/Documentation/auto-owners-approval-images/example-2.1.png b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-2.1.png
new file mode 100644
index 0000000..8c2c801
--- /dev/null
+++ b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-2.1.png
Binary files differ
diff --git a/owners/src/main/resources/Documentation/auto-owners-approval-images/example-2.2.png b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-2.2.png
new file mode 100644
index 0000000..cc1b9bd
--- /dev/null
+++ b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-2.2.png
Binary files differ
diff --git a/owners/src/main/resources/Documentation/auto-owners-approval-images/example-3.1.png b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-3.1.png
new file mode 100644
index 0000000..c7ee811
--- /dev/null
+++ b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-3.1.png
Binary files differ
diff --git a/owners/src/main/resources/Documentation/auto-owners-approval-images/example-3.2.png b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-3.2.png
new file mode 100644
index 0000000..ea28999
--- /dev/null
+++ b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-3.2.png
Binary files differ
diff --git a/owners/src/main/resources/Documentation/auto-owners-approval-images/example-4.1.png b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-4.1.png
new file mode 100644
index 0000000..463d6fd
--- /dev/null
+++ b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-4.1.png
Binary files differ
diff --git a/owners/src/main/resources/Documentation/auto-owners-approval-images/example-4.2.png b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-4.2.png
new file mode 100644
index 0000000..94c6576
--- /dev/null
+++ b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-4.2.png
Binary files differ
diff --git a/owners/src/main/resources/Documentation/auto-owners-approval-images/example-5.1.png b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-5.1.png
new file mode 100644
index 0000000..48ae37a
--- /dev/null
+++ b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-5.1.png
Binary files differ
diff --git a/owners/src/main/resources/Documentation/auto-owners-approval-images/example-5.2.png b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-5.2.png
new file mode 100644
index 0000000..8e215c1
--- /dev/null
+++ b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-5.2.png
Binary files differ
diff --git a/owners/src/main/resources/Documentation/auto-owners-approval-images/example-6.1.png b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-6.1.png
new file mode 100644
index 0000000..8c2c801
--- /dev/null
+++ b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-6.1.png
Binary files differ
diff --git a/owners/src/main/resources/Documentation/auto-owners-approval-images/example-6.2.png b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-6.2.png
new file mode 100644
index 0000000..a22de19
--- /dev/null
+++ b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-6.2.png
Binary files differ
diff --git a/owners/src/main/resources/Documentation/auto-owners-approval-images/example-6.3.png b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-6.3.png
new file mode 100644
index 0000000..7d7f67f
--- /dev/null
+++ b/owners/src/main/resources/Documentation/auto-owners-approval-images/example-6.3.png
Binary files differ
diff --git a/owners/src/main/resources/Documentation/config.md b/owners/src/main/resources/Documentation/config.md
index 553e540..9bc33cd 100644
--- a/owners/src/main/resources/Documentation/config.md
+++ b/owners/src/main/resources/Documentation/config.md
@@ -206,58 +206,11 @@
 
 ## auto-owners-approved
 
-The `auto-owners-approved` field controls a specific exception to the default
-`approverin:already-approved-by_owners` behavior. It applies when a new patch-set updates only files
-that are owned by the change owner or patch-set committer, in a situation where the normal
-`approverin:already-approved-by_owners` logic would otherwise drop that owner's previous vote.
+The optional `auto-owners-approved` field controls a specific exception to the default
+`approverin:already-approved-by_owners` copy condition behavior.
 
-The rationale is simple: if an owner already approved a change that stays entirely within code they
-own, and the next patch set is uploaded by that same owner, forcing that same person to re-apply
-the same vote adds little review value.
-
-See [copy-conditions.md](copy-conditions.md) for predicate evaluation details.
-
-This field can be configured at `OWNERS` file level and on individual matchers.
-If it is not set, it defaults to `true`.
-
-If `auto-owners-approved` is `false` for any touched file, the predicate does not use that
-self-update shortcut for the patch set. The usual `approverin:already-approved-by_owners` logic
-still applies.
-
-When a matcher defines `auto-owners-approved`, that matcher-specific value takes precedence for the
-files it matches over the surrounding `OWNERS` value.
-
-### Inheritance
-
-The usual `OWNERS` [inheritance](#global-project-owners) logic applies to `auto-owners-approved` as
-well. This includes directory `OWNERS` lookup, project `refs/meta/config` `OWNERS`, and
-parent project `OWNERS` when inheritance continues up the project hierarchy.
-
-### auto-owners-approved example
-
-Disable at `OWNERS` level:
-
-    inherited: true
-    auto-owners-approved: false
-
-With this setting, the predicate will not copy an owner's vote just because the owner is updating
-only files they own on their own change. Paths under that `OWNERS` file still participate in the
-normal copy-condition behavior.
-
-Override that setting for matched files:
-
-```yaml
-inherited: true
-auto-owners-approved: false
-matchers:
-  - suffix: .java
-    auto-owners-approved: true
-    owners:
-      - user-backend
-```
-
-Here, `.java` files matched by that rule use `auto-owners-approved: true` even though the enclosing
-`OWNERS` file sets it to `false`.
+Please refer to the [relevant paragraph](./copy-conditions.md#auto-owners-approved) in
+the [copy-conditions.md](./copy-conditions.md) documentation for details on this.
 
 ## Example 1 - OWNERS file without matchers
 
diff --git a/owners/src/main/resources/Documentation/copy-conditions.md b/owners/src/main/resources/Documentation/copy-conditions.md
index b318028..8628208 100644
--- a/owners/src/main/resources/Documentation/copy-conditions.md
+++ b/owners/src/main/resources/Documentation/copy-conditions.md
@@ -119,12 +119,202 @@
     copyCondition = approverin:already-approved-by_owners
 ```
 
-## Customizing the copy condition behaviour with the `auto-owners-approved` in `OWNERS`
+## auto-owners-approved
 
-The `approverin:already-approved-by_owners` can be fine-grained enabled or disabled using
-`auto-owners-approved` in `OWNERS`, and more narrowly on individual matchers.
-When both are present, the matcher value wins for the files that matcher selects.
-See the examples in [config.md](./config.md#auto-owners-approved).
+The `auto-owners-approved` field controls a specific exception to the default
+`approverin:already-approved-by_owners` behavior. It applies when:
 
-Details on the `auto-owners-approved` behaviour can be
-found [here](./config.md#auto-owners-approved).
+1. The new patch-set updates only files that are owned by the change owner.
+2. The change owner, the patch-set committer and the label approver are the same person.
+
+Under these conditions, if the `auto-owners-approved` field is set, and it applies to all files
+updated in the patchset, then the vote will be copied over, whilst the normal
+`approverin:already-approved-by_owners` logic would drop that owner's previous vote.
+
+The rationale is simple: if an owner already approved a change that stays entirely within code they
+own, and the next patch set is uploaded by that same owner, forcing that same person to re-apply
+the same vote adds little review value.
+
+If it is not set, it defaults to `false`.
+
+This field can be configured at `OWNERS` file level and on individual matchers.
+When a matcher defines `auto-owners-approved`, that matcher-specific value takes precedence for the
+files it matches over the surrounding `OWNERS` value.
+
+If `auto-owners-approved` is `true` for every touched file, the predicate can use that self-update
+shortcut for the patch set. Otherwise, the usual `approverin:already-approved-by_owners` logic
+still applies.
+
+When a vote was copied over to the new patchset due to the  `auto-owners-approved` applying, the UI
+will clearly show a dedicated icon on the UI, highlighting this.
+The [examples](#auto-owners-approved-examples) section shows some screenshots of what this looks
+like.
+
+### Inheritance
+
+The usual `OWNERS` [inheritance](./config.md#global-project-owners) logic applies to
+`auto-owners-approved` as well. This includes directory `OWNERS` lookup, project `refs/meta/config`
+`OWNERS`, and parent project `OWNERS` when inheritance continues up the project hierarchy.
+
+### auto-owners-approved examples
+
+Let's imagine a repository `main-repo` for which the `approverin:already-approved-by_owners` copy
+condition logic has been configured.
+
+The `main-repo` repo has the following `OWNERS` files configurations.
+
+```
+➜  main-repo tree
+.
+├── OWNERS
+└── subdir
+    ├── OWNERS
+    └── subsub
+        └── OWNERS
+```
+
+* OWNERS
+
+The root `OWNERS` file enables `auto-owners-approved` behaviour for Java (`.java`) files, which are
+owned by the `user-backend` and `user-security`.
+JavaScript (`.js`) files are owned by `user-frontend` and auto-approval logic does not apply for
+them, as per default.
+
+```yaml
+inherited: true
+matchers:
+  - suffix: .js
+    owners:
+      - user-frontend
+  - suffix: .java
+    auto-owners-approved: true
+    owners:
+      - user-backend
+      - user-security
+```
+
+* subdir/OWNERS
+
+The `subdir/OWNERS` file attributes ownership on the entire `subdir` directory to the `user-backend`
+and _enables_ `auto-approval` behaviour at directory level.
+
+```yaml
+inherited: true
+auto-owners-approved: true
+owners:
+  - user-backend
+```
+
+* subdir/subsub/OWNERS
+
+The `subdir/subsub/OWNERS` file attributes ownership on the entire `subsub` directory to the
+`user-backend` but _disables_ `auto-approval` behaviour at directory level.
+
+```yaml
+inherited: true
+auto-owners-approved: false
+owners:
+  - user-backend
+```
+
+#### Example 1 - Default behavior. auto owners approval logic does NOT apply
+
+1. The `user-frontend` adds a `.js` file and votes `+2`. The owner approval is satisfied (since
+   `user-frontend` is an owner).
+
+   ![Example 1 - owners approval satisfied](./auto-owners-approval-images/example-1.1.png "Example 1 - owners approval satisfied")
+2. The `user-frontend` modifies the content of the `.js` file and uploads a new patchset. The
+   approval is **not copied over**.
+
+   ![Example 1 - owners approval is lost](./auto-owners-approval-images/example-1.2.png "Example 1 - owners approval is lost")
+
+**EXPLANATION**: by default, the `auto-owners-approved` is `false`, so the standard
+`approverin:already-approved-by_owners` logic applies: owned files have been modified, the vote is
+not copied over.
+
+#### Example 2 - auto owners approval logic applies: Vote is copied over to new patchset
+
+1. The `user-backend` adds a `.java` file and votes `+2`. The owner approval is satisfied (since
+   `user-backend` is an owner).
+
+   ![Example 2 - owners approval satisfied](./auto-owners-approval-images/example-2.1.png "Example 2 - owners approval satisfied")
+2. The `user-backend` modifies the content of the `.java` file and uploads a new patchset. The
+   approval **is** copied over (and a special icon is displayed).
+
+   ![Example 2 - owners approval is copied](./auto-owners-approval-images/example-2.2.png "Example 2 - owners approval is copied")
+
+**EXPLANATION**: the root `OWNERS` file has a specific matcher that enables the
+`auto-owners-approved` for `.java` files.
+
+Since the change owner, the uploader and the approver are all the same person **and** only owned
+files have been modified in the new patchset, the vote is copied over.
+
+The UI shows a special icon to indicate that the approval was carried automatically because the
+`auto-owners-approved` applied.
+
+#### Example 3 - Owned and non-owned files are modified. auto owners approval logic does NOT apply
+
+1. The `user-backend` adds a `.java` file and votes `+2`. The owner approval is satisfied (since
+   `user-backend` is an owner).
+
+   ![Example 3 - owners approval satisfied](./auto-owners-approval-images/example-3.1.png "Example 3 - owners approval satisfied")
+2. The `user-backend` modifies the content of the `.java` and of a `.txt` file and uploads a new
+   patchset. The approval is **NOT copied over**.
+
+   ![Example 3 - owners approval is lost](./auto-owners-approval-images/example-3.2.png "Example 3 - owners approval is lost")
+
+**EXPLANATION**: Even though the root `OWNERS` enables `auto-owners-approved` for `.java` files, the
+change owner's new patch also touched non-owned files (the `.txt`): the conditions for which the
+patch-set is eligible for auto-approval did not apply and thus the owner approval is lost.
+
+#### Example 4 - a different owner approved the change. auto owners approval logic does NOT apply
+
+1. The `user-backend` adds a `.java` file. `user-security` votes `+2`. The owner approval is
+   satisfied (since `user-security` is also an owner of `java` files).
+
+   ![Example 4 - owners approval satisfied](./auto-owners-approval-images/example-4.1.png "Example 4 - owners approval satisfied")
+2. The `user-backend` modifies the content of the `.java` file and uploads a new patchset. The
+   approval is **NOT** copied over.
+
+   ![Example 4 - owners approval is lost](./auto-owners-approval-images/example-4.2.png "Example 4 - owners approval is lost")
+
+**EXPLANATION**: Even though the root `OWNERS` enables `auto-owners-approved` for `.java` files, the
+approval vote was given by a _different_ owner (i.e. not by the change owner): the conditions for
+which the patch-set is eligible for auto-approval did not apply and thus the owner approval is lost.
+
+#### Example 5 - auto approval not enabled for every file. auto owners approval logic does NOT apply
+
+1. The `user-backend` adds a file in the `subdir` file and votes `+2`. The owner approval is
+   satisfied (since `user-backend` is an owner of the `subdir` directory).
+
+   ![Example 5 - owners approval satisfied](./auto-owners-approval-images/example-5.1.png "Example 5 - owners approval satisfied")
+2. The `user-backend` modifies a `subdir/subsub` file and uploads a new patchset.
+   The approval is **NOT copied over**.
+
+   ![Example 5 - owners approval is lost](./auto-owners-approval-images/example-5.2.png "Example 5 - owners approval is lost")
+
+**EXPLANATION**: The `subdir/OWNERS` enables `auto-owners-approved`, whilst the
+`subdir/subsub/OWNERS` disables it. Even though the change owner is the uploader and the approver of
+the new patchset and only owned files have been touched, the `auto-owners-approved` flag was not
+enabled for every file in the patch. the conditions for which the patch-set is eligible for
+auto-approval did not apply and thus the owner approval is lost.
+
+#### Example 6 - Explicit owner approval override. auto owners approval icon is not displayed
+
+1. The `user-backend` adds a `.java` file and votes `+2`. The owner approval is satisfied (since
+   `user-backend` is an owner).
+
+   ![Example 6 - owners approval satisfied](./auto-owners-approval-images/example-6.1.png "Example 6 - owners approval satisfied")
+2. The `user-backend` modifies the content of the `.java` file and uploads a new patchset. The
+   approval **is** copied over (and a special icon is displayed).
+
+   ![Example 6 - owners approval is copied](./auto-owners-approval-images/example-6.2.png "Example 6 - owners approval is copied")
+3. The `user-security` now also approves the change. The explicit approval icon is displayed.
+
+   ![Example 6 - explicit owners approval override](./auto-owners-approval-images/example-6.3.png "Example 6 - explicit owner approval override")
+
+**EXPLANATION**: The auto owners approval icon
+is meant to highlight the fact the patch-set was _implicitly_ approved due to the
+`auto-owners-approval` logic, rather than an _explicit_ owners approval. In this context, since the
+`user-security` cast an explicit approval, the special icon would be misleading, and thus it is
+not displayed.
diff --git a/owners/src/main/resources/Documentation/rest-api.md b/owners/src/main/resources/Documentation/rest-api.md
index 3505d6c..490772f 100644
--- a/owners/src/main/resources/Documentation/rest-api.md
+++ b/owners/src/main/resources/Documentation/rest-api.md
@@ -1,8 +1,9 @@
 # Rest API
 
 The @PLUGIN@ exposes a Rest API endpoint to list the owners associated with each file that
-needs approval (`file` field), is approved (`files_approved`) and, for each owner,
-its current labels and votes (`owners_labels`):
+needs approval (`files`), is approved (`files_approved`), is approved through
+`auto-owners-approved` (`files_auto_approved`) and, for each owner, its current labels and
+votes (`owners_labels`):
 
 ```bash
 GET /changes/{change-id}/revisions/{revision-id}/owners~files-owners
@@ -25,6 +26,12 @@
       { "name":"Release Engineer", "id": 1000001 }
     ]
   },
+  "files_auto_approved": {
+    "Repository.java":[
+      { "name":"John", "id": 1000004 },
+      { "name":"Release Engineer", "id": 1000001 }
+    ]
+  },
   "owners_labels" : {
     "1000002": {
       "Verified": 1,
@@ -38,5 +45,14 @@
 
 ```
 
+`files_auto_approved` contains the files whose approval on the current patch set comes from a vote
+that was copied forward because the `auto-owners-approved` logic applies.
+See [the relevant section](./copy-conditions.md#auto-owners-approved) for more details on this.
+
+`files_auto_approved` and `files_approved` are mutually exclusive. A file that is auto-approved is
+returned only in `files_auto_approved`, and both sections return the full owner set for each file.
+If a file also has a sufficient explicit owner vote on the current patch set, it is treated as
+explicitly approved and returned only in `files_approved`.
+
 > __NOTE__: The API does not work in the case when custom label is in
 > rules.pl configuration as described in [the config.md docs](https://gerrit.googlesource.com/plugins/owners/+/refs/heads/stable-3.4/owners/src/main/resources/Documentation/config.md#example-3-owners-file-without-matchers-and-custom-owner_approves-label)
\ No newline at end of file
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/AlreadyApprovedByCopyConditionIT.java b/owners/src/test/java/com/googlesource/gerrit/owners/AlreadyApprovedByCopyConditionIT.java
index 70ded46..f9d8f04 100644
--- a/owners/src/test/java/com/googlesource/gerrit/owners/AlreadyApprovedByCopyConditionIT.java
+++ b/owners/src/test/java/com/googlesource/gerrit/owners/AlreadyApprovedByCopyConditionIT.java
@@ -348,7 +348,7 @@
   }
 
   @Test
-  public void shouldCopyApprovalWhenAllModifiedFilesAreOwnedAndAutoOwnersApprovedIsDefault()
+  public void shouldNotCopyApprovalWhenAllModifiedFilesAreOwnedAndAutoOwnersApprovedIsDefault()
       throws Exception {
     pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER));
 
@@ -358,16 +358,12 @@
     vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
     createPatchSet(changeId, BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
 
-    assertVote(changeId, BACKEND_FILES_OWNER, 2);
+    assertVote(changeId, BACKEND_FILES_OWNER, 0);
   }
 
   @Test
-  public void
-      shouldCopyApprovalWhenAllModifiedFilesAreOwnedAndAutoOwnersApprovedIsDisabledOnRepoAndEnabledOnRoot()
-          throws Exception {
-    pushOwnersToRef(
-        "inherited: true\nauto-owners-approved: false\n", "OWNERS", RefNames.REFS_CONFIG);
-
+  public void shouldCopyApprovalWhenAllModifiedFilesAreOwnedAndAutoOwnersApprovedIsEnabledOnRoot()
+      throws Exception {
     pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVED_ENABLED));
 
     ChangeIdentifier changeId =
@@ -380,12 +376,8 @@
   }
 
   @Test
-  public void
-      shouldCopyApprovalWhenAllModifiedFilesAreOwnedAndAutoOwnersApprovedIsDisabledOnRootAndEnabledOnPath()
-          throws Exception {
-    pushOwnersToRef(
-        "inherited: true\nauto-owners-approved: false\n", "OWNERS", RefNames.fullName("master"));
-
+  public void shouldCopyApprovalWhenAllModifiedFilesAreOwnedAndAutoOwnersApprovedIsEnabledOnPath()
+      throws Exception {
     pushOwnersToRef(
         ownersConfigFor(BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVED_ENABLED),
         BACKEND_OWNED_FILE_PATH + "OWNERS",
@@ -403,7 +395,7 @@
   @Test
   public void shouldNotCopyApprovalWhenAllModifiedFilesAreOwnedButApproverIsNotChangeOwner()
       throws Exception {
-    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER));
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVED_ENABLED));
 
     ChangeIdentifier changeId = createChange(NON_OWNER.id(), BACKEND_OWNED_FILE, "java content");
 
@@ -416,7 +408,7 @@
   @Test
   public void shouldNotCopyApprovalWhenAllModifiedFilesAreOwnedButUploaderNotOwner()
       throws Exception {
-    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER));
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVED_ENABLED));
 
     ChangeIdentifier changeId =
         createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
@@ -456,9 +448,9 @@
   }
 
   @Test
-  public void shouldCopyApprovalWhenAutoOwnersApprovedIsFalseButOwnedEditsAreRebaseOnly()
+  public void shouldCopyApprovalWhenAutoOwnersApprovedIsDefaultAndOwnedEditsAreRebaseOnly()
       throws Exception {
-    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVAL_DISABLED));
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER));
 
     ObjectId initialCommitId = createInitialContentFor(BACKEND_OWNED_FILE);
     PushOneCommit.Result amendL3 =
@@ -490,10 +482,13 @@
   }
 
   @Test
-  public void shouldNotCopyApprovalWhenMatcherDisablesAutoOwnersApproved() throws Exception {
-    pushOwnersToMaster(
-        matcherOwnersConfig(
-            suffixMatcherConfig(".java", BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVAL_DISABLED)));
+  public void shouldNotCopyApprovalWhenChildMatcherUsesDefaultAutoOwnersApproved()
+      throws Exception {
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER));
+    pushOwnersToRef(
+        matcherOwnersConfig(suffixMatcherConfig(".java", BACKEND_FILES_OWNER)),
+        BACKEND_OWNED_FILE_PATH + "OWNERS",
+        RefNames.fullName("master"));
 
     ChangeIdentifier changeId =
         createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
@@ -649,7 +644,7 @@
   @Test
   public void shouldNotCopyApprovalWhenPathAndMatcherFilesDoNotAllAllowAutoOwnersApproved()
       throws Exception {
-    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVAL_DISABLED));
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER));
     pushOwnersToRef(
         matcherOwnersConfig(
             suffixMatcherConfig(".java", BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVED_ENABLED)),
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersITAbstract.java b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersITAbstract.java
index ad46da6..3ee36e6 100644
--- a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersITAbstract.java
+++ b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersITAbstract.java
@@ -16,7 +16,10 @@
 package com.googlesource.gerrit.owners.restapi;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.googlesource.gerrit.owners.AlreadyApprovedByOperand.FULL_OPERAND_WITH_PLUGIN_NAME;
 
 import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.GitUtil;
@@ -25,11 +28,16 @@
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.config.GlobalPluginConfig;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ChangeIdentifier;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -37,13 +45,17 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.inject.Inject;
 import com.googlesource.gerrit.owners.common.InvalidOwnersFileException;
 import com.googlesource.gerrit.owners.common.LabelDefinition;
 import com.googlesource.gerrit.owners.entities.FilesOwnersResponse;
 import com.googlesource.gerrit.owners.entities.GroupOwner;
 import com.googlesource.gerrit.owners.entities.Owner;
 import com.googlesource.gerrit.owners.restapi.GetFilesOwners.LabelNotFoundException;
+import java.util.Arrays;
 import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -53,6 +65,12 @@
 public abstract class GetFilesOwnersITAbstract extends LightweightPluginDaemonTest {
 
   private static final String REFS_META_CONFIG = RefNames.REFS_META + "config";
+  private static final String UNOWNED_TXT_FILE = "a.txt";
+  private static final String OWNED_JAVA_FILE = "foo.java";
+  private static final Set<GroupOwner> NO_AUTO_APPROVED_OWNERS = Set.of();
+  @Inject protected ProjectOperations projectOperations;
+  @Inject protected RequestScopeOperations requestScopeOperations;
+  @Inject protected ChangeOperations changeOperations;
   protected GetFilesOwners ownersApi;
   private Owner rootOwner;
   private Owner projectOwner;
@@ -107,10 +125,10 @@
     Response<FilesOwnersResponse> resp =
         assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
 
-    assertThat(resp.value().files)
+    assertThat(resp.value().files())
         .containsExactly("a.txt", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get())));
 
-    assertThat(resp.value().ownersLabels).isEmpty();
+    assertThat(resp.value().ownersLabels()).isEmpty();
   }
 
   @Test
@@ -122,7 +140,7 @@
     Response<FilesOwnersResponse> resp =
         assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
 
-    assertThat(resp.value().ownersLabels)
+    assertThat(resp.value().ownersLabels())
         .containsExactly(admin.id().get(), Map.of(LabelId.CODE_REVIEW, 2));
   }
 
@@ -136,12 +154,139 @@
     Response<FilesOwnersResponse> resp =
         assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
 
-    assertThat(resp.value().files).isEmpty();
-    assertThat(resp.value().filesApproved)
+    assertThat(resp.value().files()).isEmpty();
+    assertThat(resp.value().filesApproved())
         .containsExactly("a.txt", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get())));
   }
 
   @Test
+  public void shouldReturnFilesAutoApprovedWhenOwnerVoteIsCopied() throws Exception {
+    setupAutoApprovalFor(admin);
+
+    ChangeIdentifier changeId = createChangeWithCopiedOwnerVote(admin);
+
+    assertFilesApproval(
+        changeId.toString(), OWNED_JAVA_FILE, NO_AUTO_APPROVED_OWNERS, owners(admin));
+  }
+
+  @Test
+  public void shouldNotReturnFilesAutoApprovedWhenOwnerExplicitlyVotesOnCurrentPatchSet()
+      throws Exception {
+    setupAutoApprovalFor(admin);
+
+    ChangeIdentifier changeId = createChangeWithCopiedOwnerVote(admin);
+    vote(admin, changeId.toString(), 2);
+
+    assertFilesApproval(
+        changeId.toString(), OWNED_JAVA_FILE, owners(admin), NO_AUTO_APPROVED_OWNERS);
+  }
+
+  @Test
+  public void shouldNotReturnFilesAutoApprovedWhenAnotherOwnerExplicitlyVotesOnCurrentPatchSet()
+      throws Exception {
+    TestAccount explicitVoteOwner = accountCreator.create("user-backend");
+    allowCodeReviewForRegisteredUsers();
+    setupAutoApprovalFor(admin, explicitVoteOwner);
+
+    ChangeIdentifier changeId = createChangeWithCopiedOwnerVote(admin);
+    vote(explicitVoteOwner, changeId.toString(), 2);
+
+    assertFilesApproval(
+        changeId.toString(),
+        OWNED_JAVA_FILE,
+        owners(admin, explicitVoteOwner),
+        NO_AUTO_APPROVED_OWNERS);
+  }
+
+  @Test
+  public void shouldNotReturnFilesAutoApprovedWhenOwnedAndUnownedFilesAreModifiedTogether()
+      throws Exception {
+    setupAutoApprovalForJavaMatcher(admin);
+
+    ChangeIdentifier changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(admin.id())
+            .file(OWNED_JAVA_FILE)
+            .content("v1")
+            .create();
+    vote(admin, changeId.toString(), 2);
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .uploader(admin.id())
+        .file(OWNED_JAVA_FILE)
+        .content("v2")
+        .file(UNOWNED_TXT_FILE)
+        .content("unowned")
+        .create();
+
+    Response<FilesOwnersResponse> response =
+        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId.toString())));
+    assertThat(response.value().files()).containsExactly(OWNED_JAVA_FILE, owners(admin));
+    assertThat(response.value().filesApproved()).isEmpty();
+    assertThat(response.value().filesAutoApproved()).isEmpty();
+  }
+
+  @Test
+  public void shouldNotReturnFilesAutoApprovedWhenOnlyUnownedFileIsAdded() throws Exception {
+    setupAutoApprovalForJavaMatcher(admin);
+
+    ChangeIdentifier changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(admin.id())
+            .file(OWNED_JAVA_FILE)
+            .content("v1")
+            .create();
+    vote(admin, changeId.toString(), 2);
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .uploader(admin.id())
+        .file(UNOWNED_TXT_FILE)
+        .content("unowned")
+        .create();
+
+    assertFilesApproval(
+        changeId.toString(), OWNED_JAVA_FILE, owners(admin), NO_AUTO_APPROVED_OWNERS);
+  }
+
+  @Test
+  public void shouldReturnFilesAutoApprovedWhenNextPatchSetAddsOnlyOwnedFile() throws Exception {
+    setupAutoApprovalForJavaMatcher(admin);
+    String ANOTHER_OWNED_JAVA_FILE = "another-" + OWNED_JAVA_FILE;
+
+    ChangeIdentifier changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(admin.id())
+            .file(OWNED_JAVA_FILE)
+            .content("owned")
+            .file(UNOWNED_TXT_FILE)
+            .content("unowned")
+            .create();
+    vote(admin, changeId.toString(), 2);
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .uploader(admin.id())
+        .file(ANOTHER_OWNED_JAVA_FILE)
+        .content("owned")
+        .create();
+
+    Response<FilesOwnersResponse> response =
+        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId.toString())));
+    assertThat(response.value().files()).isEmpty();
+    assertThat(response.value().filesApproved()).isEmpty();
+    assertThat(response.value().filesAutoApproved())
+        .containsExactly(OWNED_JAVA_FILE, owners(admin), ANOTHER_OWNED_JAVA_FILE, owners(admin));
+  }
+
+  @Test
   @GlobalPluginConfig(pluginName = "owners", name = "owners.expandGroups", value = "false")
   public void shouldReturnResponseWithUnexpandedFileOwners() throws Exception {
     addOwnerFileToRoot(true);
@@ -150,9 +295,9 @@
     Response<FilesOwnersResponse> resp =
         assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
 
-    assertThat(resp.value().files)
+    assertThat(resp.value().files())
         .containsExactly("a.txt", Sets.newHashSet(new GroupOwner(admin.username())));
-    assertThat(resp.value().filesApproved).isEmpty();
+    assertThat(resp.value().filesApproved()).isEmpty();
   }
 
   @Test
@@ -167,8 +312,8 @@
     Response<FilesOwnersResponse> resp =
         assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
 
-    assertThat(resp.value().files).isEmpty();
-    assertThat(resp.value().filesApproved)
+    assertThat(resp.value().files()).isEmpty();
+    assertThat(resp.value().filesApproved())
         .containsExactly("a.txt", Sets.newHashSet(new GroupOwner(admin.username())));
   }
 
@@ -181,9 +326,9 @@
     Response<FilesOwnersResponse> resp =
         assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
 
-    assertThat(resp.value().files)
+    assertThat(resp.value().files())
         .containsExactly("a.txt", Sets.newHashSet(new GroupOwner(admin.username())));
-    assertThat(resp.value().filesApproved).isEmpty();
+    assertThat(resp.value().filesApproved()).isEmpty();
   }
 
   @Test
@@ -195,9 +340,9 @@
     Response<FilesOwnersResponse> resp =
         assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
 
-    assertThat(resp.value().files)
+    assertThat(resp.value().files())
         .containsExactly("a.txt", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get())));
-    assertThat(resp.value().filesApproved).isEmpty();
+    assertThat(resp.value().filesApproved()).isEmpty();
   }
 
   @Test
@@ -220,12 +365,12 @@
     String changeId = createChange().getChangeId();
     Response<FilesOwnersResponse> resp =
         assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
-    assertThat(resp.value().files).containsExactly("a.txt", Sets.newHashSet(rootOwner));
+    assertThat(resp.value().files()).containsExactly("a.txt", Sets.newHashSet(rootOwner));
 
     addOwnerFileToProjectConfig(allProjects, true, user);
     resp = assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
-    assertThat(resp.value().files).containsExactly("a.txt", Sets.newHashSet(projectOwner));
-    assertThat(resp.value().filesApproved).isEmpty();
+    assertThat(resp.value().files()).containsExactly("a.txt", Sets.newHashSet(projectOwner));
+    assertThat(resp.value().filesApproved()).isEmpty();
   }
 
   @Test
@@ -298,8 +443,8 @@
     Response<FilesOwnersResponse> resp =
         assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
 
-    assertThat(resp.value().files).containsExactly("a.txt", Sets.newHashSet(rootOwner));
-    assertThat(resp.value().filesApproved).isEmpty();
+    assertThat(resp.value().files()).containsExactly("a.txt", Sets.newHashSet(rootOwner));
+    assertThat(resp.value().filesApproved()).isEmpty();
   }
 
   private void assertInheritFromProject(Project.NameKey projectNameKey) throws Exception {
@@ -310,15 +455,89 @@
     Response<FilesOwnersResponse> resp =
         assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
 
-    assertThat(resp.value().files)
+    assertThat(resp.value().files())
         .containsExactly("a.txt", Sets.newHashSet(rootOwner, projectOwner));
-    assertThat(resp.value().filesApproved).isEmpty();
+    assertThat(resp.value().filesApproved()).isEmpty();
   }
 
   private void addBrokenOwnersFileToRoot() throws Exception {
     merge(createChange(testRepo, "master", "Add OWNER file", "OWNERS", "{foo", ""));
   }
 
+  private ChangeIdentifier createChangeWithCopiedOwnerVote(TestAccount owner) throws Exception {
+    ChangeIdentifier changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(owner.id())
+            .file(OWNED_JAVA_FILE)
+            .content("v1")
+            .create();
+    vote(owner, changeId.toString(), 2);
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .uploader(owner.id())
+        .file(OWNED_JAVA_FILE)
+        .content("v2")
+        .create();
+    return changeId;
+  }
+
+  private void setupAutoApprovalFor(TestAccount... owners) throws Exception {
+    String ownersYaml =
+        Arrays.stream(owners)
+            .map(owner -> String.format("- %s\n", owner.username()))
+            .collect(Collectors.joining());
+    setupAutoApproval(
+        String.format("inherited: true\nauto-owners-approved: true\nowners:\n%s", ownersYaml));
+  }
+
+  private void setupAutoApprovalForJavaMatcher(TestAccount owner) throws Exception {
+    setupAutoApproval(
+        String.format(
+            "inherited: true\nmatchers:\n"
+                + "- suffix: .java\n"
+                + "  auto-owners-approved: true\n"
+                + "  owners:\n"
+                + "   - %s\n",
+            owner.username()));
+  }
+
+  private void setupAutoApproval(String ownersContent) throws Exception {
+    updateLabel(b -> b.setCopyCondition("approverin:" + FULL_OPERAND_WITH_PLUGIN_NAME));
+    merge(createChange(testRepo, "master", "Add OWNER file", "OWNERS", ownersContent, ""));
+  }
+
+  private void assertFilesApproval(
+      String changeId,
+      String filePath,
+      java.util.Set<GroupOwner> explicitlyApprovedOwners,
+      java.util.Set<GroupOwner> autoApprovedOwners)
+      throws Exception {
+    Response<FilesOwnersResponse> response =
+        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
+    assertThat(response.value().files()).isEmpty();
+    if (explicitlyApprovedOwners.isEmpty()) {
+      assertThat(response.value().filesApproved()).isEmpty();
+    } else {
+      assertThat(response.value().filesApproved())
+          .containsExactly(filePath, explicitlyApprovedOwners);
+    }
+    if (autoApprovedOwners.isEmpty()) {
+      assertThat(response.value().filesAutoApproved()).isEmpty();
+    } else {
+      assertThat(response.value().filesAutoApproved())
+          .containsExactly(filePath, autoApprovedOwners);
+    }
+  }
+
+  private Set<GroupOwner> owners(TestAccount... accounts) {
+    return java.util.Arrays.stream(accounts)
+        .map(account -> new Owner(account.fullName(), account.id().get()))
+        .collect(Collectors.toSet());
+  }
+
   private void addOwnerFileToProjectConfig(Project.NameKey projectNameKey, boolean inherit)
       throws Exception {
     addOwnerFileToProjectConfig(projectNameKey, inherit, user);
@@ -422,4 +641,27 @@
     }
     return clonedProject;
   }
+
+  private void vote(TestAccount user, String changeId, int vote) throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, vote));
+  }
+
+  private void updateLabel(java.util.function.Consumer<LabelType.Builder> update) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(allProjects)) {
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, update);
+      u.save();
+    }
+  }
+
+  private void allowCodeReviewForRegisteredUsers() throws Exception {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/*").group(REGISTERED_USERS).range(-2, 2))
+        .update();
+  }
 }
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersSubmitRequirementsIT.java b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersSubmitRequirementsIT.java
index 3915997..026ebdb 100644
--- a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersSubmitRequirementsIT.java
+++ b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersSubmitRequirementsIT.java
@@ -70,19 +70,19 @@
 
     Response<FilesOwnersResponse> resp =
         assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
-    assertThat(resp.value().files)
+    assertThat(resp.value().files())
         .containsExactly("foo", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get())));
-    assertThat(resp.value().ownersLabels).isEmpty();
-    assertThat(resp.value().filesApproved).isEmpty();
+    assertThat(resp.value().ownersLabels()).isEmpty();
+    assertThat(resp.value().filesApproved()).isEmpty();
 
     // give CR+1 as requested
     recommend(changeId);
 
     resp = assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
-    assertThat(resp.value().files).isEmpty();
-    assertThat(resp.value().ownersLabels)
+    assertThat(resp.value().files()).isEmpty();
+    assertThat(resp.value().ownersLabels())
         .containsExactly(admin.id().get(), Map.of(LabelId.CODE_REVIEW, 1));
-    assertThat(resp.value().filesApproved)
+    assertThat(resp.value().filesApproved())
         .containsExactly("foo", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get())));
   }
 
@@ -97,18 +97,18 @@
 
     Response<FilesOwnersResponse> resp =
         assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
-    assertThat(resp.value().files)
+    assertThat(resp.value().files())
         .containsExactly("foo", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get())));
-    assertThat(resp.value().ownersLabels).isEmpty();
-    assertThat(resp.value().filesApproved).isEmpty();
+    assertThat(resp.value().ownersLabels()).isEmpty();
+    assertThat(resp.value().filesApproved()).isEmpty();
 
     // give LabelFoo+1 as requested
     gApi.changes().id(changeId).current().review(new ReviewInput().label(label, 1));
 
     resp = assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
-    assertThat(resp.value().files).isEmpty();
-    assertThat(resp.value().ownersLabels).containsEntry(admin.id().get(), Map.of(label, 1));
-    assertThat(resp.value().filesApproved)
+    assertThat(resp.value().files()).isEmpty();
+    assertThat(resp.value().ownersLabels()).containsEntry(admin.id().get(), Map.of(label, 1));
+    assertThat(resp.value().filesApproved())
         .containsExactly("foo", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get())));
   }
 
diff --git a/owners/web/gr-files.ts b/owners/web/gr-files.ts
index dbacad8..b13594d 100644
--- a/owners/web/gr-files.ts
+++ b/owners/web/gr-files.ts
@@ -48,21 +48,25 @@
 const STATUS_CODE = {
   MISSING: 'missing',
   APPROVED: 'approved',
+  AUTO_APPROVED: 'autoApproved',
 };
 
 const STATUS_ICON = {
   [STATUS_CODE.MISSING]: 'schedule',
   [STATUS_CODE.APPROVED]: 'check',
+  [STATUS_CODE.AUTO_APPROVED]: 'published_with_changes',
 };
 
 const FILE_STATUS = {
   [FileStatus.NEEDS_APPROVAL]: STATUS_CODE.MISSING,
   [FileStatus.APPROVED]: STATUS_CODE.APPROVED,
+  [FileStatus.AUTO_APPROVED]: STATUS_CODE.AUTO_APPROVED,
 };
 
 const HOVER_HEADING = {
   [STATUS_CODE.MISSING]: "Needs Owners' Approval",
   [STATUS_CODE.APPROVED]: 'Approved by Owners',
+  [STATUS_CODE.AUTO_APPROVED]: 'Auto-approved by Owners',
 };
 
 const DISPLAY_OWNERS_FOR_FILE_LIMIT = 5;
@@ -251,6 +255,9 @@
         :host([file-status='approved']) gr-icon.status {
           color: var(--positive-green-text-color);
         }
+        :host([file-status='autoApproved']) gr-icon.status {
+          color: var(--positive-green-text-color);
+        }
         :host([file-status='missing']) gr-icon.status {
           color: #ffa62f;
         }
@@ -459,7 +466,9 @@
   if (
     hasOwnersSubmitRequirement(change) &&
     filesOwners &&
-    (filesOwners.files || filesOwners.files_approved)
+    (filesOwners.files ||
+      filesOwners.files_approved ||
+      filesOwners.files_auto_approved)
   ) {
     return !userRole || userRole === UserRole.ANONYMOUS;
   }
@@ -476,7 +485,13 @@
 
   const fileOwners = (filesOwners.files ?? {})[path];
   const fileApprovers = (filesOwners.files_approved ?? {})[path];
-  if (fileApprovers) {
+  const fileAutoApprovers = (filesOwners.files_auto_approved ?? {})[path];
+  if (fileAutoApprovers) {
+    return {
+      fileStatus: FileStatus.AUTO_APPROVED,
+      owners: fileAutoApprovers,
+    };
+  } else if (fileApprovers) {
     return {
       fileStatus: FileStatus.APPROVED,
       owners: fileApprovers,
diff --git a/owners/web/gr-files_test.ts b/owners/web/gr-files_test.ts
index d05f6e7..d8ede81 100644
--- a/owners/web/gr-files_test.ts
+++ b/owners/web/gr-files_test.ts
@@ -46,9 +46,11 @@
 suite('owners status tests', () => {
   const path = 'readme.md';
   const approvedPath = 'db.sql';
+  const autoApprovedPath = 'foo.java';
   const filesOwners = {
     files: {[path]: [{name: 'John', id: 1}]},
     files_approved: {[approvedPath]: [{name: 'Merry', id: 2}]},
+    files_auto_approved: {[autoApprovedPath]: [{name: 'Merry', id: 2}]},
   } as unknown as FilesOwners;
 
   suite('shouldHide tests', () => {
@@ -261,6 +263,34 @@
       );
     });
 
+    test('getFileOwnership - should return owners from `files_auto_approved` when file is auto-approved', () => {
+      const filesOwnersWithExplicitAndAutoApprovers = {
+        files_approved: {
+          [approvedPath]: [
+            {name: 'Merry', id: 2},
+            {name: 'John', id: 1},
+          ],
+        },
+        files_auto_approved: {
+          [autoApprovedPath]: [{name: 'Merry', id: 2}],
+        },
+      } as unknown as FilesOwners;
+
+      assert.equal(
+        deepEqual(
+          getFileOwnership(
+            autoApprovedPath,
+            filesOwnersWithExplicitAndAutoApprovers
+          ),
+          {
+            fileStatus: FileStatus.AUTO_APPROVED,
+            owners: [{name: 'Merry', id: 2}],
+          } as FileOwnership
+        ),
+        true
+      );
+    });
+
     test('getFileOwnership - should return `FileOwnership` with `NOT_OWNED` fileStatus when file has no owner', () => {
       assert.equal(
         deepEqual(getFileOwnership(path, emptyFilesOwners), {
diff --git a/owners/web/gr-owned-files.ts b/owners/web/gr-owned-files.ts
index 1e64398..428e85d 100644
--- a/owners/web/gr-owned-files.ts
+++ b/owners/web/gr-owned-files.ts
@@ -53,11 +53,13 @@
 export enum FileStatus {
   NEEDS_APPROVAL = 'missing',
   APPROVED = 'approved',
+  AUTO_APPROVED = 'autoApproved',
 }
 
 const STATUS_ICON = {
   [FileStatus.NEEDS_APPROVAL]: 'schedule',
   [FileStatus.APPROVED]: 'check',
+  [FileStatus.AUTO_APPROVED]: 'published_with_changes',
 };
 
 interface OwnedFileInfo {
@@ -125,7 +127,8 @@
           padding: var(--spacing-xs) 0px;
           margin-left: 3px;
         }
-        :host([files-status='approved']) gr-icon.status {
+        :host([files-status='approved']) gr-icon.status,
+        :host([files-status='autoApproved']) gr-icon.status {
           color: var(--positive-green-text-color);
         }
         :host([files-status='missing']) gr-icon.status {
@@ -207,6 +210,9 @@
     filesApproved: number,
     filesPending: number
   ): [string, string] {
+    const allFilesApproved =
+      filesStatus === FileStatus.APPROVED ||
+      filesStatus === FileStatus.AUTO_APPROVED;
     const pendingInfo =
       filesPending > 0
         ? `Missing approval for ${filesPending} file${
@@ -220,13 +226,11 @@
           } already approved.`
         : '';
     const info = `${
-      FileStatus.APPROVED === filesStatus
+      allFilesApproved
         ? approvedInfo
         : `${pendingInfo}${filesApproved > 0 ? ` and ${approvedInfo}` : '.'}`
     }`;
-    const summary = `${
-      FileStatus.APPROVED === filesStatus ? 'Approved' : 'Missing'
-    }`;
+    const summary = `${allFilesApproved ? 'Approved' : 'Missing'}`;
     return [info, summary];
   }
 }
@@ -284,6 +288,9 @@
         gr-icon.status.approved {
           color: var(--positive-green-text-color);
         }
+        gr-icon.status.autoApproved {
+          color: var(--positive-green-text-color);
+        }
         gr-icon.status.missing {
           color: #ffa62f;
         }
@@ -499,7 +506,9 @@
   if (
     !owner ||
     !filesOwners ||
-    (!filesOwners.files && !filesOwners.files_approved)
+    (!filesOwners.files &&
+      !filesOwners.files_approved &&
+      !filesOwners.files_auto_approved)
   ) {
     return;
   }
@@ -520,10 +529,17 @@
     FileStatus.APPROVED,
     emailWithoutDomain
   );
+  const autoApprovedFiles = collectOwnedFiles(
+    owner,
+    groupPrefix,
+    filesOwners.files_auto_approved ?? {},
+    FileStatus.AUTO_APPROVED,
+    emailWithoutDomain
+  );
   return {
-    ownedFiles: [...pendingFiles, ...approvedFiles],
+    ownedFiles: [...pendingFiles, ...approvedFiles, ...autoApprovedFiles],
     numberOfPending: pendingFiles.length,
-    numberOfApproved: approvedFiles.length,
+    numberOfApproved: approvedFiles.length + autoApprovedFiles.length,
   } as OwnedFilesInfo;
 }
 
diff --git a/owners/web/gr-owned-files_test.ts b/owners/web/gr-owned-files_test.ts
index fd37f67..540017e 100644
--- a/owners/web/gr-owned-files_test.ts
+++ b/owners/web/gr-owned-files_test.ts
@@ -93,6 +93,40 @@
       );
     });
 
+    test('ownedFiles - should return auto-approved files', () => {
+      const autoApprovedFilesOwners = {
+        files_auto_approved: {
+          [ownedApprovedFile]: [fileOwner(1)],
+        },
+      } as unknown as FilesOwners;
+      assert.equal(
+        deepEqual(ownedFiles(owner, autoApprovedFilesOwners), {
+          ownedFiles: [
+            {file: ownedApprovedFile, status: FileStatus.AUTO_APPROVED},
+          ],
+          numberOfApproved: 1,
+          numberOfPending: 0,
+        }),
+        true
+      );
+    });
+
+    test('ownedFiles - should return explicitly approved files without auto approval flag', () => {
+      const explicitlyApprovedFilesOwners = {
+        files_approved: {
+          [ownedApprovedFile]: [fileOwner(1)],
+        },
+      } as unknown as FilesOwners;
+      assert.equal(
+        deepEqual(ownedFiles(owner, explicitlyApprovedFilesOwners), {
+          ownedFiles: [{file: ownedApprovedFile, status: FileStatus.APPROVED}],
+          numberOfApproved: 1,
+          numberOfPending: 0,
+        }),
+        true
+      );
+    });
+
     test('ownedFiles - should match file owner through email without domain name', () => {
       const filesOwners = {
         files: {
diff --git a/owners/web/owners-model.ts b/owners/web/owners-model.ts
index 48e7e08..1edb46b 100644
--- a/owners/web/owners-model.ts
+++ b/owners/web/owners-model.ts
@@ -50,6 +50,7 @@
 export enum FileStatus {
   NEEDS_APPROVAL = 'NEEDS_APPROVAL',
   APPROVED = 'APPROVED',
+  AUTO_APPROVED = 'AUTO_APPROVED',
   NOT_OWNED = 'NOT_OWNED',
 }
 
diff --git a/owners/web/owners-service.ts b/owners/web/owners-service.ts
index 0a57362..974aa58 100644
--- a/owners/web/owners-service.ts
+++ b/owners/web/owners-service.ts
@@ -52,6 +52,7 @@
 export interface FilesOwners {
   files: OwnedFiles;
   files_approved: OwnedFiles;
+  files_auto_approved?: OwnedFiles;
   owners_labels: OwnersLabels;
 }