Extend 'GetFilesOwners' with Owners submit requirements

When 'owners.enableSubmitRequirements = true' calculate file owners with
label and score defined in the `OWNERS` file. In case when no custom
label rules are defined there fallback to the Gerrit's CodeReview label
defnition.

Notes:
* the existing `GetFilesOwnersIT` content was moved to the common
  abstract class so that current functionality could be also confirmed
  when submit requirement were enabled (in
  `GetFilesOwnersSubmitRequirementsIT`)
* `GetFilesOwnersSubmitRequirementsIT` contains extra cases to cover
  label defined in the `OWNERS` file

Bug: Issue 40015590
Change-Id: Id03e2485f3980ac91bc2041ebfc89d05b431a612
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 6bdb9a2..5f5b1fb 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
@@ -20,7 +20,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
-import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -69,9 +68,7 @@
   private final PathOwnersEntriesCache cache;
 
   static final String MISSING_CODE_REVIEW_LABEL =
-      String.format(
-          "Cannot calculate file onwers state when %s label is not configured",
-          LabelId.CODE_REVIEW);
+      "Cannot calculate file owners state when review label is not configured";
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   @Inject
@@ -97,18 +94,6 @@
       throws AuthException, BadRequestException, ResourceConflictException, Exception {
     Change change = revision.getChange();
     ChangeData changeData = revision.getChangeResource().getChangeData();
-    Short codeReviewMaxValue =
-        changeData
-            .getLabelTypes()
-            .byLabel(LabelId.CODE_REVIEW)
-            .map(LabelType::getMaxPositive)
-            .orElseThrow(
-                () -> {
-                  logger.atInfo().log(
-                      "Project %s has no Code-Review label configured hence getting file owners is not possible.",
-                      revision.getProject());
-                  return new ResourceNotFoundException(MISSING_CODE_REVIEW_LABEL);
-                });
 
     Project.NameKey project = change.getProject();
     List<Project.NameKey> projectParents =
@@ -149,34 +134,73 @@
 
       Map<Integer, Map<String, Integer>> ownersLabels = getLabels(change.getChangeId());
 
+      LabelAndScore label = getLabelDefinition(owners, changeData);
+
       Map<String, Set<GroupOwner>> filesWithPendingOwners =
           Maps.filterEntries(
               fileToOwners,
               (fileOwnerEntry) ->
                   !isApprovedByOwner(
-                      fileExpandedOwners.get(fileOwnerEntry.getKey()),
-                      ownersLabels,
-                      codeReviewMaxValue));
+                      fileExpandedOwners.get(fileOwnerEntry.getKey()), ownersLabels, label));
 
       return Response.ok(new FilesOwnersResponse(ownersLabels, filesWithPendingOwners));
     }
   }
 
+  private LabelAndScore getLabelDefinition(PathOwners owners, ChangeData changeData)
+      throws ResourceNotFoundException {
+
+    try {
+      return Optional.of(pluginSettings.enableSubmitRequirement())
+          .filter(Boolean::booleanValue)
+          .flatMap(enabled -> getLabelFromOwners(owners, changeData))
+          .orElseGet(
+              () ->
+                  new LabelAndScore(
+                      LabelId.CODE_REVIEW, getMaxScoreForLabel(changeData, LabelId.CODE_REVIEW)));
+    } catch (LabelNotFoundException e) {
+      logger.atInfo().withCause(e).log("Invalid configuration");
+      throw new ResourceNotFoundException(MISSING_CODE_REVIEW_LABEL, e);
+    }
+  }
+
+  private Optional<LabelAndScore> getLabelFromOwners(PathOwners owners, ChangeData changeData)
+      throws LabelNotFoundException {
+    return owners
+        .getLabel()
+        .map(
+            label ->
+                new LabelAndScore(
+                    label.getName(),
+                    label
+                        .getScore()
+                        .orElseGet(() -> getMaxScoreForLabel(changeData, label.getName()))));
+  }
+
+  private short getMaxScoreForLabel(ChangeData changeData, String labelId)
+      throws LabelNotFoundException {
+    return changeData
+        .getLabelTypes()
+        .byLabel(labelId)
+        .map(label -> label.getMaxPositive())
+        .orElseThrow(() -> new LabelNotFoundException(changeData.change().getProject(), labelId));
+  }
+
   private boolean isApprovedByOwner(
       Set<GroupOwner> fileOwners,
       Map<Integer, Map<String, Integer>> ownersLabels,
-      short codeReviewMaxValue) {
+      LabelAndScore label) {
     return fileOwners.stream()
         .filter(owner -> owner instanceof Owner)
         .map(owner -> ((Owner) owner).getId())
-        .flatMap(ownerId -> codeReviewLabelValue(ownersLabels, ownerId))
-        .anyMatch(value -> value == codeReviewMaxValue);
+        .flatMap(ownerId -> codeReviewLabelValue(ownersLabels, ownerId, label.getLabelId()))
+        .anyMatch(value -> value >= label.getScore());
   }
 
   private Stream<Integer> codeReviewLabelValue(
-      Map<Integer, Map<String, Integer>> ownersLabels, int ownerId) {
+      Map<Integer, Map<String, Integer>> ownersLabels, int ownerId, String labelId) {
     return Stream.ofNullable(ownersLabels.get(ownerId))
-        .flatMap(m -> Stream.ofNullable(m.get(LabelId.CODE_REVIEW)));
+        .flatMap(m -> Stream.ofNullable(m.get(labelId)));
   }
 
   /**
@@ -227,4 +251,30 @@
         .get(accountId)
         .map(as -> new Owner(as.account().fullName(), as.account().id().get()));
   }
+
+  static class LabelNotFoundException extends RuntimeException {
+    private static final long serialVersionUID = 1L;
+
+    LabelNotFoundException(Project.NameKey project, String labelId) {
+      super(String.format("Project %s has no %s label defined", project, labelId));
+    }
+  }
+
+  private static class LabelAndScore {
+    private final String labelId;
+    private final short score;
+
+    private LabelAndScore(String labelId, short score) {
+      this.labelId = labelId;
+      this.score = score;
+    }
+
+    private String getLabelId() {
+      return labelId;
+    }
+
+    private short getScore() {
+      return score;
+    }
+  }
 }
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersIT.java b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersIT.java
index 1c551ec..76c4962 100644
--- a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersIT.java
+++ b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersIT.java
@@ -15,348 +15,9 @@
 
 package com.googlesource.gerrit.owners.restapi;
 
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import com.google.gerrit.acceptance.GitUtil;
-import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit.Result;
-import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestPlugin;
 import com.google.gerrit.acceptance.UseLocalDisk;
-import com.google.gerrit.acceptance.config.GlobalPluginConfig;
-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.client.SubmitType;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.server.project.testing.TestLabels;
-import com.googlesource.gerrit.owners.entities.FilesOwnersResponse;
-import com.googlesource.gerrit.owners.entities.GroupOwner;
-import com.googlesource.gerrit.owners.entities.Owner;
-import java.util.Arrays;
-import javax.servlet.http.HttpServletResponse;
-import org.apache.commons.compress.utils.Sets;
-import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.transport.FetchResult;
-import org.eclipse.jgit.util.FS;
-import org.junit.Test;
 
 @TestPlugin(name = "owners", sysModule = "com.googlesource.gerrit.owners.OwnersModule")
 @UseLocalDisk
-public class GetFilesOwnersIT extends LightweightPluginDaemonTest {
-
-  private static final String REFS_META_CONFIG = RefNames.REFS_META + "config";
-  private GetFilesOwners ownersApi;
-  private Owner rootOwner;
-  private Owner projectOwner;
-  private NameKey parentProjectName;
-  private NameKey childProjectName;
-  private TestRepository<InMemoryRepository> childRepo;
-  private TestRepository<InMemoryRepository> parentRepo;
-  private TestRepository<InMemoryRepository> allProjectsRepo;
-
-  @Override
-  public void setUpTestPlugin() throws Exception {
-    super.setUpTestPlugin();
-
-    rootOwner = new Owner(admin.fullName(), admin.id().get());
-    projectOwner = new Owner(user.fullName(), user.id().get());
-    ownersApi = plugin.getSysInjector().getInstance(GetFilesOwners.class);
-
-    parentProjectName =
-        createProjectOverAPI("parent", allProjects, true, SubmitType.FAST_FORWARD_ONLY);
-    parentRepo = cloneProjectWithMetaRefs(parentProjectName);
-
-    childProjectName =
-        createProjectOverAPI("child", parentProjectName, true, SubmitType.FAST_FORWARD_ONLY);
-    childRepo = cloneProject(childProjectName);
-
-    allProjectsRepo = cloneProjectWithMetaRefs(allProjects);
-  }
-
-  @Test
-  public void shouldReturnExactFileOwners() throws Exception {
-    addOwnerFileToRoot(true);
-    assertChangeHasOwners(createChange().getChangeId());
-  }
-
-  @Test
-  public void shouldReturnExactFileOwnersWhenOwnersIsSetToAllProjects() throws Exception {
-    addOwnerFileWithMatchers(allProjectsRepo, REFS_META_CONFIG, true);
-    assertChangeHasOwners(createChange(childRepo).getChangeId());
-  }
-
-  @Test
-  public void shouldReturnExactFileOwnersWhenOwnersIsSetToParentProject() throws Exception {
-    addOwnerFileWithMatchers(parentRepo, REFS_META_CONFIG, true);
-    assertChangeHasOwners(createChange(childRepo).getChangeId());
-  }
-
-  @Test
-  public void shouldReturnOwnersLabelsWhenNotApprovedByOwners() throws Exception {
-    addOwnerFileToRoot(true);
-    String changeId = createChange().getChangeId();
-
-    Response<FilesOwnersResponse> resp =
-        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
-
-    assertThat(resp.value().files)
-        .containsExactly("a.txt", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get())));
-
-    assertThat(resp.value().ownersLabels).isEmpty();
-  }
-
-  @Test
-  public void shouldReturnEmptyResponseWhenApprovedByOwners() throws Exception {
-    addOwnerFileToRoot(true);
-    String changeId = createChange().getChangeId();
-    approve(changeId);
-
-    Response<FilesOwnersResponse> resp =
-        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
-
-    assertThat(resp.value().files).isEmpty();
-  }
-
-  @Test
-  @GlobalPluginConfig(pluginName = "owners", name = "owners.expandGroups", value = "false")
-  public void shouldReturnResponseWithUnexpandedFileOwners() throws Exception {
-    addOwnerFileToRoot(true);
-    String changeId = createChange().getChangeId();
-
-    Response<FilesOwnersResponse> resp =
-        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
-
-    assertThat(resp.value().files)
-        .containsExactly("a.txt", Sets.newHashSet(new GroupOwner(admin.username())));
-  }
-
-  @Test
-  @GlobalPluginConfig(pluginName = "owners", name = "owners.expandGroups", value = "false")
-  public void shouldReturnEmptyResponseWhenApprovedByOwnersWithUnexpandedFileOwners()
-      throws Exception {
-    addOwnerFileToRoot(true);
-    String changeId = createChange().getChangeId();
-    approve(changeId);
-
-    Response<FilesOwnersResponse> resp =
-        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
-
-    assertThat(resp.value().files).isEmpty();
-  }
-
-  @Test
-  @GlobalPluginConfig(pluginName = "owners", name = "owners.expandGroups", value = "false")
-  public void shouldReturnResponseWithUnexpandedFileMatchersOwners() throws Exception {
-    addOwnerFileWithMatchersToRoot(true);
-    String changeId = createChange().getChangeId();
-
-    Response<FilesOwnersResponse> resp =
-        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
-
-    assertThat(resp.value().files)
-        .containsExactly("a.txt", Sets.newHashSet(new GroupOwner(admin.username())));
-  }
-
-  @Test
-  @UseLocalDisk
-  public void shouldReturnInheritedOwnersFromProjectsOwners() throws Exception {
-    assertInheritFromProject(project);
-  }
-
-  @Test
-  @UseLocalDisk
-  public void shouldReturnInheritedOwnersFromParentProjectsOwners() throws Exception {
-    assertInheritFromProject(allProjects);
-  }
-
-  @Test
-  @UseLocalDisk
-  public void shouldReflectChangesInParentProject() throws Exception {
-    addOwnerFileToProjectConfig(allProjects, true, admin);
-
-    String changeId = createChange().getChangeId();
-    Response<FilesOwnersResponse> resp =
-        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
-    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));
-  }
-
-  @Test
-  @UseLocalDisk
-  public void shouldNotReturnInheritedOwnersFromProjectsOwners() throws Exception {
-    assertNotInheritFromProject(project);
-  }
-
-  @Test
-  @UseLocalDisk
-  public void shouldNotReturnInheritedOwnersFromParentProjectsOwners() throws Exception {
-    addOwnerFileToProjectConfig(project, false);
-    assertNotInheritFromProject(allProjects);
-  }
-
-  @Test
-  @UseLocalDisk
-  public void shouldThrowExceptionWhenCodeReviewLabelIsNotConfigured() throws Exception {
-    addOwnerFileToProjectConfig(project, false);
-    replaceCodeReviewWithLabel(
-        TestLabels.label(
-            "Foo", TestLabels.value(1, "Foo is fine"), TestLabels.value(-1, "Foo is not fine")));
-    String changeId = createChange().getChangeId();
-
-    ResourceNotFoundException thrown =
-        assertThrows(
-            ResourceNotFoundException.class,
-            () -> ownersApi.apply(parseCurrentRevisionResource(changeId)));
-    assertThat(thrown).hasMessageThat().isEqualTo(GetFilesOwners.MISSING_CODE_REVIEW_LABEL);
-  }
-
-  private void replaceCodeReviewWithLabel(LabelType label) throws Exception {
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig().getLabelSections().remove(LabelId.CODE_REVIEW);
-      u.getConfig().upsertLabelType(label);
-      u.save();
-    }
-  }
-
-  private static <T> Response<T> assertResponseOk(Response<T> response) {
-    assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_OK);
-    return response;
-  }
-
-  private void assertNotInheritFromProject(Project.NameKey projectNameKey) throws Exception {
-    addOwnerFileToRoot(false);
-    addOwnerFileToProjectConfig(projectNameKey, true);
-
-    String changeId = createChange().getChangeId();
-    assertChangeHasOwners(changeId);
-  }
-
-  private void assertChangeHasOwners(String changeId)
-      throws AuthException, BadRequestException, ResourceConflictException, Exception {
-    Response<FilesOwnersResponse> resp =
-        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
-
-    assertThat(resp.value().files).containsExactly("a.txt", Sets.newHashSet(rootOwner));
-  }
-
-  private void assertInheritFromProject(Project.NameKey projectNameKey) throws Exception {
-    addOwnerFileToRoot(true);
-    addOwnerFileToProjectConfig(projectNameKey, true);
-
-    String changeId = createChange().getChangeId();
-    Response<FilesOwnersResponse> resp =
-        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
-
-    assertThat(resp.value().files)
-        .containsExactly("a.txt", Sets.newHashSet(rootOwner, projectOwner));
-  }
-
-  private void addOwnerFileToProjectConfig(Project.NameKey projectNameKey, boolean inherit)
-      throws Exception {
-    addOwnerFileToProjectConfig(projectNameKey, inherit, user);
-  }
-
-  private void addOwnerFileToProjectConfig(
-      Project.NameKey projectNameKey, boolean inherit, TestAccount account) throws Exception {
-    TestRepository<InMemoryRepository> project = cloneProject(projectNameKey);
-    GitUtil.fetch(project, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
-    project.reset(RefNames.REFS_CONFIG);
-    pushFactory
-        .create(
-            admin.newIdent(),
-            project,
-            "Add OWNER file",
-            "OWNERS",
-            String.format(
-                "inherited: %s\nmatchers:\n" + "- suffix: .txt\n  owners:\n   - %s\n",
-                inherit, account.email()))
-        .to(RefNames.REFS_CONFIG);
-  }
-
-  private void addOwnerFileToRoot(boolean inherit) throws Exception {
-    // Add OWNERS file to root:
-    //
-    // inherited: true
-    // owners:
-    // - admin
-    merge(
-        createChange(
-            testRepo,
-            "master",
-            "Add OWNER file",
-            "OWNERS",
-            String.format("inherited: %s\nowners:\n- %s\n", inherit, admin.email()),
-            ""));
-  }
-
-  private void addOwnerFileWithMatchersToRoot(boolean inherit) throws Exception {
-    addOwnerFileWithMatchers(testRepo, "master", inherit);
-  }
-
-  private void addOwnerFileWithMatchers(TestRepository<?> repo, String targetRef, boolean inherit)
-      throws Exception {
-    // Add OWNERS file to root:
-    //
-    // inherited: true
-    // matchers:
-    // - suffix: .txt
-    //   owners:
-    //   - admin@mail.com
-    Result changeCreated =
-        createChange(
-            repo,
-            targetRef,
-            "Add OWNER file",
-            "OWNERS",
-            String.format(
-                "inherited: %s\nmatchers:\n" + "- suffix: .txt\n  owners:\n   - %s\n",
-                inherit, admin.email()),
-            "");
-    changeCreated.assertOkStatus();
-    merge(changeCreated);
-  }
-
-  public TestRepository<InMemoryRepository> cloneProjectWithMetaRefs(Project.NameKey project)
-      throws Exception {
-    String uri = registerRepoConnection(project, admin);
-    String initialRef = "refs/remotes/origin/config";
-    DfsRepositoryDescription desc = new DfsRepositoryDescription("clone of " + project.get());
-
-    InMemoryRepository.Builder b = new InMemoryRepository.Builder().setRepositoryDescription(desc);
-    if (uri.startsWith("ssh://")) {
-      // SshTransport depends on a real FS to read ~/.ssh/config, but InMemoryRepository by default
-      // uses a null FS.
-      // Avoid leaking user state into our tests.
-      b.setFS(FS.detect().setUserHome(null));
-    }
-    InMemoryRepository dest = b.build();
-    Config cfg = dest.getConfig();
-    cfg.setString("remote", "origin", "url", uri);
-    cfg.setStringList(
-        "remote",
-        "origin",
-        "fetch",
-        Arrays.asList(
-            "+refs/heads/*:refs/remotes/origin/*", "+refs/meta/config:refs/remotes/origin/config"));
-    TestRepository<InMemoryRepository> testRepo = GitUtil.newTestRepository(dest);
-    FetchResult result = testRepo.git().fetch().setRemote("origin").call();
-    if (result.getTrackingRefUpdate(initialRef) != null) {
-      testRepo.reset(initialRef);
-    }
-    return testRepo;
-  }
-}
+public class GetFilesOwnersIT extends GetFilesOwnersITAbstract {}
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
new file mode 100644
index 0000000..936f3d3
--- /dev/null
+++ b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersITAbstract.java
@@ -0,0 +1,361 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+// implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.owners.restapi;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.config.GlobalPluginConfig;
+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.client.SubmitType;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.server.project.testing.TestLabels;
+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 javax.servlet.http.HttpServletResponse;
+import org.apache.commons.compress.utils.Sets;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.transport.FetchResult;
+import org.eclipse.jgit.util.FS;
+import org.junit.Test;
+
+public abstract class GetFilesOwnersITAbstract extends LightweightPluginDaemonTest {
+
+  private static final String REFS_META_CONFIG = RefNames.REFS_META + "config";
+  protected GetFilesOwners ownersApi;
+  private Owner rootOwner;
+  private Owner projectOwner;
+  private NameKey parentProjectName;
+  private NameKey childProjectName;
+  private TestRepository<InMemoryRepository> childRepo;
+  private TestRepository<InMemoryRepository> parentRepo;
+  private TestRepository<InMemoryRepository> allProjectsRepo;
+
+  @Override
+  public void setUpTestPlugin() throws Exception {
+    super.setUpTestPlugin();
+
+    rootOwner = new Owner(admin.fullName(), admin.id().get());
+    projectOwner = new Owner(user.fullName(), user.id().get());
+    ownersApi = plugin.getSysInjector().getInstance(GetFilesOwners.class);
+
+    parentProjectName =
+        createProjectOverAPI("parent", allProjects, true, SubmitType.FAST_FORWARD_ONLY);
+    parentRepo = cloneProjectWithMetaRefs(parentProjectName);
+
+    childProjectName =
+        createProjectOverAPI("child", parentProjectName, true, SubmitType.FAST_FORWARD_ONLY);
+    childRepo = cloneProject(childProjectName);
+
+    allProjectsRepo = cloneProjectWithMetaRefs(allProjects);
+  }
+
+  @Test
+  public void shouldReturnExactFileOwners() throws Exception {
+    addOwnerFileToRoot(true);
+    assertChangeHasOwners(createChange().getChangeId());
+  }
+
+  @Test
+  public void shouldReturnExactFileOwnersWhenOwnersIsSetToAllProjects() throws Exception {
+    addOwnerFileWithMatchers(allProjectsRepo, REFS_META_CONFIG, true);
+    assertChangeHasOwners(createChange(childRepo).getChangeId());
+  }
+
+  @Test
+  public void shouldReturnExactFileOwnersWhenOwnersIsSetToParentProject() throws Exception {
+    addOwnerFileWithMatchers(parentRepo, REFS_META_CONFIG, true);
+    assertChangeHasOwners(createChange(childRepo).getChangeId());
+  }
+
+  @Test
+  public void shouldReturnOwnersLabelsWhenNotApprovedByOwners() throws Exception {
+    addOwnerFileToRoot(true);
+    String changeId = createChange().getChangeId();
+
+    Response<FilesOwnersResponse> resp =
+        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
+
+    assertThat(resp.value().files)
+        .containsExactly("a.txt", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get())));
+
+    assertThat(resp.value().ownersLabels).isEmpty();
+  }
+
+  @Test
+  public void shouldReturnEmptyResponseWhenApprovedByOwners() throws Exception {
+    addOwnerFileToRoot(true);
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+
+    Response<FilesOwnersResponse> resp =
+        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
+
+    assertThat(resp.value().files).isEmpty();
+  }
+
+  @Test
+  @GlobalPluginConfig(pluginName = "owners", name = "owners.expandGroups", value = "false")
+  public void shouldReturnResponseWithUnexpandedFileOwners() throws Exception {
+    addOwnerFileToRoot(true);
+    String changeId = createChange().getChangeId();
+
+    Response<FilesOwnersResponse> resp =
+        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
+
+    assertThat(resp.value().files)
+        .containsExactly("a.txt", Sets.newHashSet(new GroupOwner(admin.username())));
+  }
+
+  @Test
+  @GlobalPluginConfig(pluginName = "owners", name = "owners.expandGroups", value = "false")
+  public void shouldReturnEmptyResponseWhenApprovedByOwnersWithUnexpandedFileOwners()
+      throws Exception {
+    addOwnerFileToRoot(true);
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+
+    Response<FilesOwnersResponse> resp =
+        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
+
+    assertThat(resp.value().files).isEmpty();
+  }
+
+  @Test
+  @GlobalPluginConfig(pluginName = "owners", name = "owners.expandGroups", value = "false")
+  public void shouldReturnResponseWithUnexpandedFileMatchersOwners() throws Exception {
+    addOwnerFileWithMatchersToRoot(true);
+    String changeId = createChange().getChangeId();
+
+    Response<FilesOwnersResponse> resp =
+        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
+
+    assertThat(resp.value().files)
+        .containsExactly("a.txt", Sets.newHashSet(new GroupOwner(admin.username())));
+  }
+
+  @Test
+  @UseLocalDisk
+  public void shouldReturnInheritedOwnersFromProjectsOwners() throws Exception {
+    assertInheritFromProject(project);
+  }
+
+  @Test
+  @UseLocalDisk
+  public void shouldReturnInheritedOwnersFromParentProjectsOwners() throws Exception {
+    assertInheritFromProject(allProjects);
+  }
+
+  @Test
+  @UseLocalDisk
+  public void shouldReflectChangesInParentProject() throws Exception {
+    addOwnerFileToProjectConfig(allProjects, true, admin);
+
+    String changeId = createChange().getChangeId();
+    Response<FilesOwnersResponse> resp =
+        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
+    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));
+  }
+
+  @Test
+  @UseLocalDisk
+  public void shouldNotReturnInheritedOwnersFromProjectsOwners() throws Exception {
+    assertNotInheritFromProject(project);
+  }
+
+  @Test
+  @UseLocalDisk
+  public void shouldNotReturnInheritedOwnersFromParentProjectsOwners() throws Exception {
+    addOwnerFileToProjectConfig(project, false);
+    assertNotInheritFromProject(allProjects);
+  }
+
+  @Test
+  @UseLocalDisk
+  public void shouldThrowExceptionWhenCodeReviewLabelIsNotConfigured() throws Exception {
+    addOwnerFileToProjectConfig(project, false);
+    replaceCodeReviewWithLabel(
+        TestLabels.label(
+            "Foo", TestLabels.value(1, "Foo is fine"), TestLabels.value(-1, "Foo is not fine")));
+    String changeId = createChange().getChangeId();
+
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> ownersApi.apply(parseCurrentRevisionResource(changeId)));
+    assertThat(thrown).hasMessageThat().isEqualTo(GetFilesOwners.MISSING_CODE_REVIEW_LABEL);
+    assertThat(thrown).hasCauseThat().isInstanceOf(LabelNotFoundException.class);
+  }
+
+  protected void replaceCodeReviewWithLabel(LabelType label) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(allProjects)) {
+      u.getConfig().getLabelSections().remove(LabelId.CODE_REVIEW);
+      u.getConfig().upsertLabelType(label);
+      u.save();
+    }
+  }
+
+  protected static <T> Response<T> assertResponseOk(Response<T> response) {
+    assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_OK);
+    return response;
+  }
+
+  private void assertNotInheritFromProject(Project.NameKey projectNameKey) throws Exception {
+    addOwnerFileToRoot(false);
+    addOwnerFileToProjectConfig(projectNameKey, true);
+
+    String changeId = createChange().getChangeId();
+    assertChangeHasOwners(changeId);
+  }
+
+  private void assertChangeHasOwners(String changeId)
+      throws AuthException, BadRequestException, ResourceConflictException, Exception {
+    Response<FilesOwnersResponse> resp =
+        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
+
+    assertThat(resp.value().files).containsExactly("a.txt", Sets.newHashSet(rootOwner));
+  }
+
+  private void assertInheritFromProject(Project.NameKey projectNameKey) throws Exception {
+    addOwnerFileToRoot(true);
+    addOwnerFileToProjectConfig(projectNameKey, true);
+
+    String changeId = createChange().getChangeId();
+    Response<FilesOwnersResponse> resp =
+        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
+
+    assertThat(resp.value().files)
+        .containsExactly("a.txt", Sets.newHashSet(rootOwner, projectOwner));
+  }
+
+  private void addOwnerFileToProjectConfig(Project.NameKey projectNameKey, boolean inherit)
+      throws Exception {
+    addOwnerFileToProjectConfig(projectNameKey, inherit, user);
+  }
+
+  private void addOwnerFileToProjectConfig(
+      Project.NameKey projectNameKey, boolean inherit, TestAccount account) throws Exception {
+    TestRepository<InMemoryRepository> project = cloneProject(projectNameKey);
+    GitUtil.fetch(project, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
+    project.reset(RefNames.REFS_CONFIG);
+    pushFactory
+        .create(
+            admin.newIdent(),
+            project,
+            "Add OWNER file",
+            "OWNERS",
+            String.format(
+                "inherited: %s\nmatchers:\n" + "- suffix: .txt\n  owners:\n   - %s\n",
+                inherit, account.email()))
+        .to(RefNames.REFS_CONFIG);
+  }
+
+  private void addOwnerFileToRoot(boolean inherit) throws Exception {
+    // Add OWNERS file to root:
+    //
+    // inherited: true
+    // owners:
+    // - admin
+    merge(
+        createChange(
+            testRepo,
+            "master",
+            "Add OWNER file",
+            "OWNERS",
+            String.format("inherited: %s\nowners:\n- %s\n", inherit, admin.email()),
+            ""));
+  }
+
+  private void addOwnerFileWithMatchersToRoot(boolean inherit) throws Exception {
+    addOwnerFileWithMatchers(testRepo, "master", inherit);
+  }
+
+  private void addOwnerFileWithMatchers(TestRepository<?> repo, String targetRef, boolean inherit)
+      throws Exception {
+    // Add OWNERS file to root:
+    //
+    // inherited: true
+    // matchers:
+    // - suffix: .txt
+    //   owners:
+    //   - admin@mail.com
+    Result changeCreated =
+        createChange(
+            repo,
+            targetRef,
+            "Add OWNER file",
+            "OWNERS",
+            String.format(
+                "inherited: %s\nmatchers:\n" + "- suffix: .txt\n  owners:\n   - %s\n",
+                inherit, admin.email()),
+            "");
+    changeCreated.assertOkStatus();
+    merge(changeCreated);
+  }
+
+  public TestRepository<InMemoryRepository> cloneProjectWithMetaRefs(Project.NameKey project)
+      throws Exception {
+    String uri = registerRepoConnection(project, admin);
+    String initialRef = "refs/remotes/origin/config";
+    DfsRepositoryDescription desc = new DfsRepositoryDescription("clone of " + project.get());
+
+    InMemoryRepository.Builder b = new InMemoryRepository.Builder().setRepositoryDescription(desc);
+    if (uri.startsWith("ssh://")) {
+      // SshTransport depends on a real FS to read ~/.ssh/config, but InMemoryRepository by default
+      // uses a null FS.
+      // Avoid leaking user state into our tests.
+      b.setFS(FS.detect().setUserHome(null));
+    }
+    InMemoryRepository dest = b.build();
+    Config cfg = dest.getConfig();
+    cfg.setString("remote", "origin", "url", uri);
+    cfg.setStringList(
+        "remote",
+        "origin",
+        "fetch",
+        Arrays.asList(
+            "+refs/heads/*:refs/remotes/origin/*", "+refs/meta/config:refs/remotes/origin/config"));
+    TestRepository<InMemoryRepository> testRepo = GitUtil.newTestRepository(dest);
+    FetchResult result = testRepo.git().fetch().setRemote("origin").call();
+    if (result.getTrackingRefUpdate(initialRef) != null) {
+      testRepo.reset(initialRef);
+    }
+    return testRepo;
+  }
+}
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
new file mode 100644
index 0000000..8172611
--- /dev/null
+++ b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersSubmitRequirementsIT.java
@@ -0,0 +1,143 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+// implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.owners.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 com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+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.LabelDefinition;
+import com.googlesource.gerrit.owners.entities.FilesOwnersResponse;
+import com.googlesource.gerrit.owners.entities.Owner;
+import java.nio.file.Files;
+import java.nio.file.StandardOpenOption;
+import java.util.Map;
+import org.apache.commons.compress.utils.Sets;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+@TestPlugin(name = "owners", sysModule = "com.googlesource.gerrit.owners.OwnersModule")
+@UseLocalDisk
+public class GetFilesOwnersSubmitRequirementsIT extends GetFilesOwnersITAbstract {
+  @Inject private ProjectOperations projectOperations;
+
+  @Override
+  public void setUpTestPlugin() throws Exception {
+    Config pluginCfg = pluginConfig.getGlobalPluginConfig("owners");
+    // enable submit requirements and store them to the file as there is no `ConfigSuite` mechanism
+    // for plugin config and there is no other way (but adding it to each test case) to enable it
+    // globally
+    pluginCfg.setBoolean("owners", null, "enableSubmitRequirement", true);
+    Files.writeString(
+        server.getSitePath().resolve("etc").resolve("owners.config"),
+        pluginCfg.toText(),
+        StandardOpenOption.CREATE,
+        StandardOpenOption.APPEND);
+    super.setUpTestPlugin();
+  }
+
+  @Test
+  public void shouldRequireConfiguredCodeReviewScore() throws Exception {
+    // configure submit requirement to require CR+1 only
+    addOwnerFileToRoot(LabelDefinition.parse("Code-Review,1").get(), admin);
+
+    String changeId = createChange("Add a file", "foo", "bar").getChangeId();
+
+    Response<FilesOwnersResponse> resp =
+        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
+    assertThat(resp.value().files)
+        .containsExactly("foo", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get())));
+    assertThat(resp.value().ownersLabels).isEmpty();
+
+    // give CR+1 as requested
+    recommend(changeId);
+
+    resp = assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
+    assertThat(resp.value().files).isEmpty();
+    assertThat(resp.value().ownersLabels)
+        .containsExactly(admin.id().get(), Map.of(LabelId.CODE_REVIEW, 1));
+  }
+
+  @Test
+  public void shouldRequireConfiguredLabelAndScore() throws Exception {
+    // configure submit requirement to require LabelFoo+1
+    String label = "LabelFoo";
+    addOwnerFileToRoot(LabelDefinition.parse(String.format("%s,1", label)).get(), admin);
+    replaceCodeReviewWithLabel(label);
+
+    String changeId = createChange("Add a file", "foo", "bar").getChangeId();
+
+    Response<FilesOwnersResponse> resp =
+        assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId)));
+    assertThat(resp.value().files)
+        .containsExactly("foo", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get())));
+    assertThat(resp.value().ownersLabels).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));
+  }
+
+  private void addOwnerFileToRoot(LabelDefinition label, TestAccount u) throws Exception {
+    // Add OWNERS file to root:
+    //
+    // inherited: true
+    // label: label,score # score is optional
+    // owners:
+    // - u.email()
+    String owners =
+        String.format(
+            "inherited: true\nlabel: %s\nowners:\n- %s\n",
+            String.format(
+                "%s%s",
+                label.getName(),
+                label.getScore().map(value -> String.format(",%d", value)).orElse("")),
+            u.email());
+    pushFactory
+        .create(admin.newIdent(), testRepo, "Add OWNER file", "OWNERS", owners)
+        .to(RefNames.fullName("master"))
+        .assertOkStatus();
+  }
+
+  private void replaceCodeReviewWithLabel(String labelId) throws Exception {
+    LabelType label =
+        TestLabels.label(labelId, TestLabels.value(1, "OK"), TestLabels.value(-1, "Not OK"));
+
+    replaceCodeReviewWithLabel(label);
+
+    // grant label to RegisteredUsers so that it is vote-able
+    String heads = RefNames.REFS_HEADS + "*";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(label.getName()).ref(heads).group(REGISTERED_USERS).range(-1, 1))
+        .update();
+  }
+}