Allow to configure a file extension for the code owner config files

Allows to use different owner configurations for upstream and internal
in the same repository. E.g. if upstream uses 'OWNERS' code owner config
files (no file extension configured) one could set 'internal' as file
extension internally so that internally 'OWNERS.internal' files are used
and the existing 'OWNERS' files are ignored.

This is important since otherwise the import of upstream code that uses
'OWNERS' files might not be possible (e.g. if upstream uses a different
syntax or the upstream 'OWNERS' files contain users that do not exist
interally the 'OWNERS' files may be rejected as invalid).

Signed-off-by: Edwin Kempin <ekempin@google.com>
Change-Id: I75fb012bc11fab909cfce9c3f40888c8bc03e1d0
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInfo.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInfo.java
index e9b3fb6..4ebcca9 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInfo.java
@@ -20,6 +20,9 @@
  * <p>This class determines the JSON format of code owner project configuration in the REST API.
  */
 public class CodeOwnerProjectConfigInfo {
+  /** The general code owners configuration. */
+  public GeneralInfo general;
+
   /**
    * The code owners status configuration.
    *
diff --git a/java/com/google/gerrit/plugins/codeowners/api/GeneralInfo.java b/java/com/google/gerrit/plugins/codeowners/api/GeneralInfo.java
new file mode 100644
index 0000000..47d2434
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/api/GeneralInfo.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.api;
+
+/**
+ * Representation of the general code owners configuration in the REST API.
+ *
+ * <p>This class determines the JSON format of the general code owners configuration in the REST
+ * API.
+ */
+public class GeneralInfo {
+  /**
+   * The file extension that should be used for code owner config files in this project.
+   *
+   * <p>Unset if no file extension is used.
+   */
+  public String fileExtension;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
index 4812e14..14607ee 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -41,31 +42,35 @@
 public abstract class AbstractFileBasedCodeOwnerBackend implements CodeOwnerBackend {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
   private final GitRepositoryManager repoManager;
   private final PersonIdent serverIdent;
   private final MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory;
   private final RetryHelper retryHelper;
-  private final String fileName;
+  private final String defaultFileName;
   private final CodeOwnerConfigParser codeOwnerConfigParser;
 
   protected AbstractFileBasedCodeOwnerBackend(
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       GitRepositoryManager repoManager,
       @GerritPersonIdent PersonIdent serverIdent,
       MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
       RetryHelper retryHelper,
-      String fileName,
+      String defaultFileName,
       CodeOwnerConfigParser codeOwnerConfigParser) {
+    this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.repoManager = repoManager;
     this.serverIdent = serverIdent;
     this.metaDataUpdateInternalFactory = metaDataUpdateInternalFactory;
     this.retryHelper = retryHelper;
-    this.fileName = fileName;
+    this.defaultFileName = defaultFileName;
     this.codeOwnerConfigParser = codeOwnerConfigParser;
   }
 
   @Override
   public final Optional<CodeOwnerConfig> getCodeOwnerConfig(
       CodeOwnerConfig.Key codeOwnerConfigKey, @Nullable ObjectId revision) {
+    String fileName = getFileName(codeOwnerConfigKey.project());
     if (codeOwnerConfigKey.fileName().isPresent()
         && !fileName.equals(codeOwnerConfigKey.fileName().get())) {
       // The file name can mismatch if we resolve imported code owner configs. When code owner
@@ -90,6 +95,11 @@
     }
   }
 
+  private String getFileName(Project.NameKey project) {
+    return defaultFileName
+        + codeOwnersPluginConfiguration.getFileExtension(project).map(ext -> "." + ext).orElse("");
+  }
+
   @Override
   public final Optional<CodeOwnerConfig> upsertCodeOwnerConfig(
       CodeOwnerConfig.Key codeOwnerConfigKey,
@@ -116,7 +126,11 @@
     try (Repository repository = repoManager.openRepository(codeOwnerConfigKey.project())) {
       CodeOwnerConfigFile codeOwnerConfigFile =
           CodeOwnerConfigFile.load(
-                  fileName, codeOwnerConfigParser, repository, null, codeOwnerConfigKey)
+                  getFileName(codeOwnerConfigKey.project()),
+                  codeOwnerConfigParser,
+                  repository,
+                  null,
+                  codeOwnerConfigKey)
               .setCodeOwnerConfigUpdate(codeOwnerConfigUpdate);
 
       try (MetaDataUpdate metaDataUpdate =
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
index ddced7b..215d8fc 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.GlobMatcher;
 import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
+import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -41,12 +42,14 @@
 
   @Inject
   FindOwnersBackend(
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       FindOwnersCodeOwnerConfigParser codeOwnerConfigParser,
       GitRepositoryManager repoManager,
       @GerritPersonIdent PersonIdent serverIdent,
       MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
       RetryHelper retryHelper) {
     super(
+        codeOwnersPluginConfiguration,
         repoManager,
         serverIdent,
         metaDataUpdateInternalFactory,
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackend.java
index 63cc779..c7235de 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackend.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
 import com.google.gerrit.plugins.codeowners.backend.SimplePathExpressionMatcher;
+import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -45,12 +46,14 @@
 
   @Inject
   ProtoBackend(
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       GitRepositoryManager repoManager,
       @GerritPersonIdent PersonIdent serverIdent,
       MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
       RetryHelper retryHelper,
       ProtoCodeOwnerConfigParser codeOwnerConfigParser) {
     super(
+        codeOwnersPluginConfiguration,
         repoManager,
         serverIdent,
         metaDataUpdateInternalFactory,
diff --git a/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java b/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java
index e70a87e..5b4eda8 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java
+++ b/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java
@@ -57,6 +57,7 @@
   private final String pluginName;
   private final PluginConfigFactory pluginConfigFactory;
   private final ProjectCache projectCache;
+  private final GeneralConfig generalConfig;
   private final StatusConfig statusConfig;
   private final BackendConfig backendConfig;
   private final RequiredApprovalConfig requiredApprovalConfig;
@@ -67,6 +68,7 @@
       @PluginName String pluginName,
       PluginConfigFactory pluginConfigFactory,
       ProjectCache projectCache,
+      GeneralConfig generalConfig,
       StatusConfig statusConfig,
       BackendConfig backendConfig,
       RequiredApprovalConfig requiredApprovalConfig,
@@ -74,6 +76,7 @@
     this.pluginName = pluginName;
     this.pluginConfigFactory = pluginConfigFactory;
     this.projectCache = projectCache;
+    this.generalConfig = generalConfig;
     this.statusConfig = statusConfig;
     this.backendConfig = backendConfig;
     this.requiredApprovalConfig = requiredApprovalConfig;
@@ -81,6 +84,17 @@
   }
 
   /**
+   * Gets the file extension that is configured for the given project.
+   *
+   * @param project the project for which the configured file extension should be returned
+   * @return the file extension that is configured for the given project
+   */
+  public Optional<String> getFileExtension(Project.NameKey project) {
+    requireNonNull(project, "project");
+    return generalConfig.getFileExtension(getPluginConfig(project));
+  }
+
+  /**
    * Whether the code owners functionality is disabled for the given branch.
    *
    * <p>Callers must ensure that the project of the specified branch exists. If the project doesn't
diff --git a/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java b/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java
new file mode 100644
index 0000000..0c7519b
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.config;
+
+import static com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Class to read the general code owners configuration from {@code gerrit.config} and from {@code
+ * code-owners.config} in {@code refs/meta/config}.
+ *
+ * <p>Default values are configured in {@code gerrit.config}.
+ *
+ * <p>The default values can be overridden on project-level in {@code code-owners.config} in {@code
+ * refs/meta/config}.
+ *
+ * <p>Projects that have no configuration inherit the configuration from their parent projects.
+ */
+@Singleton
+public class GeneralConfig {
+  @VisibleForTesting public static final String KEY_FILE_EXTENSION = "fileExtension";
+
+  private final PluginConfig pluginConfigFromGerritConfig;
+
+  @Inject
+  GeneralConfig(@PluginName String pluginName, PluginConfigFactory pluginConfigFactory) {
+    this.pluginConfigFromGerritConfig = pluginConfigFactory.getFromGerritConfig(pluginName);
+  }
+
+  /**
+   * Gets the file extension that should be used for code owner config files in the given project.
+   *
+   * @param pluginConfig the plugin config from which the file extension should be read.
+   * @return the file extension that should be used for code owner config files in the given
+   *     project, {@link Optional#empty()} if no file extension should be used
+   */
+  Optional<String> getFileExtension(Config pluginConfig) {
+    requireNonNull(pluginConfig, "pluginConfig");
+
+    String fileExtension = pluginConfig.getString(SECTION_CODE_OWNERS, null, KEY_FILE_EXTENSION);
+    if (fileExtension != null) {
+      return Optional.of(fileExtension);
+    }
+
+    return Optional.ofNullable(pluginConfigFromGerritConfig.getString(KEY_FILE_EXTENSION));
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java
index aebe6d8..6ee07df 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.plugins.codeowners.api.BackendInfo;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerProjectConfigInfo;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnersStatusInfo;
+import com.google.gerrit.plugins.codeowners.api.GeneralInfo;
 import com.google.gerrit.plugins.codeowners.api.RequiredApprovalInfo;
 import com.google.gerrit.plugins.codeowners.config.RequiredApproval;
 import java.util.Map;
@@ -35,11 +36,13 @@
   static CodeOwnerProjectConfigInfo format(
       boolean isDisabled,
       ImmutableList<BranchNameKey> disabledBranches,
+      @Nullable String fileExtension,
       String backendId,
       ImmutableMap<BranchNameKey, String> backendIdsPerBranch,
       RequiredApproval requiredApproval,
       @Nullable RequiredApproval overrideApproval) {
     CodeOwnerProjectConfigInfo info = new CodeOwnerProjectConfigInfo();
+    info.general = formatGeneralInfo(fileExtension);
     info.status = formatStatusInfo(isDisabled, disabledBranches);
     info.backend = formatBackendInfo(backendId, backendIdsPerBranch);
     info.requiredApproval = formatRequiredApprovalInfo(requiredApproval);
@@ -49,6 +52,13 @@
   }
 
   @VisibleForTesting
+  static GeneralInfo formatGeneralInfo(@Nullable String fileExtension) {
+    GeneralInfo generalInfo = new GeneralInfo();
+    generalInfo.fileExtension = fileExtension;
+    return generalInfo;
+  }
+
+  @VisibleForTesting
   @Nullable
   static CodeOwnersStatusInfo formatStatusInfo(
       boolean isDisabled, ImmutableList<BranchNameKey> disabledBranches) {
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerProjectConfig.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerProjectConfig.java
index 6fd5a0d..aedb3ee 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerProjectConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerProjectConfig.java
@@ -78,6 +78,9 @@
         CodeOwnerProjectConfigJson.format(
             isDisabled,
             disabledBranches,
+            codeOwnersPluginConfiguration
+                .getFileExtension(projectResource.getNameKey())
+                .orElse(null),
             backendId,
             backendIdsPerBranch,
             requiredApproval,
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java
index 7d71466..8aff6c8 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
 import com.google.gerrit.plugins.codeowners.config.BackendConfig;
 import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.config.GeneralConfig;
 import com.google.gerrit.plugins.codeowners.config.OverrideApprovalConfig;
 import com.google.gerrit.plugins.codeowners.config.RequiredApprovalConfig;
 import com.google.inject.Inject;
@@ -80,6 +81,8 @@
   public void getDefaultConfig() throws Exception {
     CodeOwnerProjectConfigInfo codeOwnerProjectConfigInfo =
         projectCodeOwnersApiFactory.project(project).getConfig();
+    assertThat(codeOwnerProjectConfigInfo.general.fileExtension).isNull();
+    assertThat(codeOwnerProjectConfigInfo.backend.idsByBranch).isNull();
     assertThat(codeOwnerProjectConfigInfo.backend.id)
         .isEqualTo(CodeOwnerBackendId.getBackendId(backendConfig.getDefaultBackend().getClass()));
     assertThat(codeOwnerProjectConfigInfo.backend.idsByBranch).isNull();
@@ -91,6 +94,14 @@
   }
 
   @Test
+  public void getConfigWithConfiguredFileExtension() throws Exception {
+    configureFileExtension(project, "foo");
+    CodeOwnerProjectConfigInfo codeOwnerProjectConfigInfo =
+        projectCodeOwnersApiFactory.project(project).getConfig();
+    assertThat(codeOwnerProjectConfigInfo.general.fileExtension).isEqualTo("foo");
+  }
+
+  @Test
   public void getConfigWithConfiguredBackend() throws Exception {
     String otherBackendId = getOtherCodeOwnerBackend(backendConfig.getDefaultBackend());
     configureBackend(project, otherBackendId);
@@ -165,6 +176,11 @@
     assertThat(codeOwnerProjectConfigInfo.overrideApproval.value).isEqualTo(2);
   }
 
+  private void configureFileExtension(Project.NameKey project, String fileExtension)
+      throws Exception {
+    setConfig(project, null, GeneralConfig.KEY_FILE_EXTENSION, fileExtension);
+  }
+
   private void configureBackend(Project.NameKey project, String backendName) throws Exception {
     configureBackend(project, null, backendName);
   }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackendTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackendTest.java
index 597e22a..2590797 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackendTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackendTest.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerConfigSubject.assertThatOptional;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
@@ -99,6 +100,21 @@
   }
 
   @Test
+  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
+  public void getCodeOwnerConfigWithFileExtension() throws Exception {
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        CodeOwnerConfig.Key.create(project, "master", "/", getFileName() + ".foo");
+    CodeOwnerConfig codeOwnerConfigInRepository =
+        testCodeOwnerConfigStorage.writeCodeOwnerConfig(
+            codeOwnerConfigKey,
+            b -> b.addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(admin.email())));
+
+    Optional<CodeOwnerConfig> codeOwnerConfig =
+        codeOwnerBackend.getCodeOwnerConfig(codeOwnerConfigKey, null);
+    assertThatOptional(codeOwnerConfig).value().isEqualTo(codeOwnerConfigInRepository);
+  }
+
+  @Test
   public void getCodeOwnerConfigFromOldRevision() throws Exception {
     CodeOwnerConfig.Key codeOwnerConfigKey = CodeOwnerConfig.Key.create(project, "master", "/");
     CodeOwnerConfig codeOwnerConfigInRepository =
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD b/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
index 5f4715f..aa620c1 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
@@ -24,6 +24,7 @@
     name = "testbases",
     srcs = glob(["Abstract*.java"]),
     deps = [
+        "//java/com/google/gerrit/acceptance/config",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
@@ -39,4 +40,3 @@
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing/backend:testutil",
     ],
 )
-
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java b/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java
index 591cee8..7d84002 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.plugins.codeowners.config;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -710,6 +710,46 @@
     assertThat(codeOwnersPluginConfiguration.areExperimentalRestEndpointsEnabled()).isFalse();
   }
 
+  @Test
+  public void cannotGetFileExtensionForNullBranch() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class, () -> codeOwnersPluginConfiguration.getFileExtension(null));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void getFileExtensionIfNoneIsConfigured() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
+  public void getFileExtensionIfNoneIsConfiguredOnProjectLevel() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).value().isEqualTo("foo");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
+  public void fileExtensionOnProjectLevelOverridesDefaultFileExtension() throws Exception {
+    configureFileExtension(project, "bar");
+    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).value().isEqualTo("bar");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
+  public void fileExtensionIsInheritedFromParentProject() throws Exception {
+    configureFileExtension(allProjects, "bar");
+    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).value().isEqualTo("bar");
+  }
+
+  @Test
+  public void inheritedFileExtensionCanBeOverridden() throws Exception {
+    configureFileExtension(allProjects, "foo");
+    configureFileExtension(project, "bar");
+    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).value().isEqualTo("bar");
+  }
+
   private void configureDisabled(Project.NameKey project, String disabled) throws Exception {
     setCodeOwnersConfig(project, null, StatusConfig.KEY_DISABLED, disabled);
   }
@@ -748,6 +788,11 @@
         project, null, OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL, requiredApproval);
   }
 
+  private void configureFileExtension(Project.NameKey project, String fileExtension)
+      throws Exception {
+    setCodeOwnersConfig(project, null, GeneralConfig.KEY_FILE_EXTENSION, fileExtension);
+  }
+
   private AutoCloseable registerTestBackend() {
     RegistrationHandle registrationHandle =
         ((PrivateInternals_DynamicMapImpl<CodeOwnerBackend>) codeOwnerBackends)
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java
new file mode 100644
index 0000000..2a71910
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
+import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_FILE_EXTENSION;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.OptionalSubject.assertThat;
+
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link GeneralConfig}. */
+public class GeneralConfigTest extends AbstractCodeOwnersTest {
+  private GeneralConfig generalConfig;
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    generalConfig = plugin.getSysInjector().getInstance(GeneralConfig.class);
+  }
+
+  @Test
+  public void cannotGetFileExtensionForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(NullPointerException.class, () -> generalConfig.getFileExtension(null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noFileExtensionConfigured() throws Exception {
+    assertThat(generalConfig.getFileExtension(new Config())).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
+  public void fileExtensionIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+      throws Exception {
+    assertThat(generalConfig.getFileExtension(new Config())).value().isEqualTo("foo");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
+  public void fileExtensionInPluginConfigOverridesFileExtensionInGerritConfig() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_FILE_EXTENSION, "bar");
+    assertThat(generalConfig.getFileExtension(cfg)).value().isEqualTo("bar");
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java
index 9347c18..b9441e7 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java
@@ -102,6 +102,7 @@
         CodeOwnerProjectConfigJson.format(
             true,
             ImmutableList.of(),
+            "foo",
             CodeOwnerBackendId.FIND_OWNERS.getBackendId(),
             ImmutableMap.of(
                 BranchNameKey.create(project, "master"),
@@ -112,6 +113,7 @@
             RequiredApproval.create(LabelType.withDefaultValues("Owners-Override"), (short) 1));
     assertThat(codeOwnerProjectConfigInfo.status.disabled).isTrue();
     assertThat(codeOwnerProjectConfigInfo.status.disabledBranches).isNull();
+    assertThat(codeOwnerProjectConfigInfo.general.fileExtension).isEqualTo("foo");
     assertThat(codeOwnerProjectConfigInfo.backend.id)
         .isEqualTo(CodeOwnerBackendId.FIND_OWNERS.getBackendId());
     assertThat(codeOwnerProjectConfigInfo.backend.idsByBranch)
@@ -129,6 +131,7 @@
         CodeOwnerProjectConfigJson.format(
             true,
             ImmutableList.of(BranchNameKey.create(project, "master")),
+            null,
             CodeOwnerBackendId.FIND_OWNERS.getBackendId(),
             ImmutableMap.of(),
             RequiredApproval.create(LabelType.withDefaultValues("Code-Review"), (short) 2),
@@ -143,6 +146,7 @@
         CodeOwnerProjectConfigJson.format(
             false,
             ImmutableList.of(),
+            null,
             CodeOwnerBackendId.FIND_OWNERS.getBackendId(),
             ImmutableMap.of(),
             RequiredApproval.create(LabelType.withDefaultValues("Code-Review"), (short) 2),
@@ -156,6 +160,7 @@
         CodeOwnerProjectConfigJson.format(
             false,
             ImmutableList.of(BranchNameKey.create(project, "master")),
+            null,
             CodeOwnerBackendId.FIND_OWNERS.getBackendId(),
             ImmutableMap.of(),
             RequiredApproval.create(LabelType.withDefaultValues("Code-Review"), (short) 2),
@@ -176,6 +181,7 @@
                 CodeOwnerProjectConfigJson.format(
                     false,
                     null,
+                    null,
                     CodeOwnerBackendId.FIND_OWNERS.getBackendId(),
                     ImmutableMap.of(),
                     requiredApproval,
@@ -192,7 +198,13 @@
             NullPointerException.class,
             () ->
                 CodeOwnerProjectConfigJson.format(
-                    false, ImmutableList.of(), null, ImmutableMap.of(), requiredApproval, null));
+                    false,
+                    ImmutableList.of(),
+                    null,
+                    null,
+                    ImmutableMap.of(),
+                    requiredApproval,
+                    null));
     assertThat(npe).hasMessageThat().isEqualTo("backendId");
   }
 
@@ -207,6 +219,7 @@
                 CodeOwnerProjectConfigJson.format(
                     false,
                     ImmutableList.of(),
+                    null,
                     CodeOwnerBackendId.FIND_OWNERS.getBackendId(),
                     null,
                     requiredApproval,
@@ -223,6 +236,7 @@
                 CodeOwnerProjectConfigJson.format(
                     false,
                     ImmutableList.of(),
+                    null,
                     CodeOwnerBackendId.FIND_OWNERS.getBackendId(),
                     ImmutableMap.of(),
                     null,
@@ -236,10 +250,26 @@
         CodeOwnerProjectConfigJson.format(
             false,
             ImmutableList.of(),
+            null,
             CodeOwnerBackendId.FIND_OWNERS.getBackendId(),
             ImmutableMap.of(),
             RequiredApproval.create(LabelType.withDefaultValues("Code-Review"), (short) 2),
             null);
     assertThat(codeOwnerProjectConfigInfo.overrideApproval).isNull();
   }
+
+  @Test
+  public void formatCodeOwnerProjectConfigForNullFileExtension() throws Exception {
+    CodeOwnerProjectConfigInfo codeOwnerProjectConfigInfo =
+        CodeOwnerProjectConfigJson.format(
+            false,
+            ImmutableList.of(),
+            null,
+            CodeOwnerBackendId.FIND_OWNERS.getBackendId(),
+            ImmutableMap.of(),
+            RequiredApproval.create(LabelType.withDefaultValues("Code-Review"), (short) 2),
+            null);
+    assertThat(codeOwnerProjectConfigInfo.general).isNotNull();
+    assertThat(codeOwnerProjectConfigInfo.general.fileExtension).isNull();
+  }
 }
diff --git a/resources/Documentation/config.md b/resources/Documentation/config.md
index 1c70fae..fc933c6 100644
--- a/resources/Documentation/config.md
+++ b/resources/Documentation/config.md
@@ -20,6 +20,18 @@
         [Backends](backends.html) page.\
         By default `find-owners`.
 
+<a id="pluginCodeOwnersExtension">plugin.@PLUGIN@.fileExtension</a>
+:       The file extension that should be used for code owner config files.\
+        Allows to use different owner configurations for upstream and internal
+        in the same repository. E.g. if upstream uses `OWNERS` code owner config
+        files (no file extension configured) one could set `internal` as file
+        extension internally so that internally `OWNERS.internal` files are used
+        and the existing `OWNERS` files are ignored.\
+        Can be overridden per project by setting
+        [codeOwners.fileExtension](#codeOwnersFileExtension) in
+        `@PLUGIN@.config`.\
+        By default unset (no file extension is used).
+
 <a id="pluginCodeOwnersRequiredApproval">plugin.@PLUGIN@.requiredApproval</a>
 :       Approval that is required from code owners to approve the files in a
         change.\
@@ -89,6 +101,21 @@
         If not set, the project level configuration
         [codeOwners.backend](#codeOwnersBackend) is used.
 
+<a id="codeOwnersFileExtension">codeOwners.fileExtension</a>
+:       The file extension that should be used for the code owner config files
+        in this project.\
+        Allows to use different owner configurations for upstream and internal
+        in the same repository. E.g. if upstream uses `OWNERS` code owner config
+        files (no file extension configured) one could set `internal` as file
+        extension internally so that internally `OWNERS.internal` files are used
+        and the existing `OWNERS` files are ignored.\
+        Overrides the global setting
+        [plugin.@PLUGIN@.fileExtension](#pluginCodeOwnersFileExtension) in
+        `gerrit.config`.\
+        If not set, the global setting
+        [plugin.@PLUGIN@.fileExtension](#pluginCodeOwnersFileExtension) in
+        `gerrit.config` is used.
+
 <a id="codeOwnersRequiredApproval">codeOwners.requiredApproval</a>
 :       Approval that is required from code owners to approve the files in a
         change.\
diff --git a/resources/Documentation/rest-api.md b/resources/Documentation/rest-api.md
index 953e6e3..87df122 100644
--- a/resources/Documentation/rest-api.md
+++ b/resources/Documentation/rest-api.md
@@ -277,6 +277,7 @@
 
 | Field Name |          | Description |
 | ---------- | -------- | ----------- |
+| `general`  || The general code owners configuration as [GeneralInfo](#general-info) entity.
 | `status`   | optional | The code owner status configuration as [CodeOwnersStatusInfo](#code-owners-status-info) entity. Contains information about whether the code owners functionality is disabled for the project or for any branch. Not set if the code owners functionality is neither disabled for the project nor for any branch.
 | `backend`  || The code owner backend configuration as [BackendInfo](#backend-info) entity.
 | `required_approval` || The approval that is required from code owners to approve the files in a change as [RequiredApprovalInfo](#required-approval-info) entity. The required approval defines which approval counts as code owner approval.
@@ -329,6 +330,14 @@
 | `old_path_status` | optional | The code owner status for the old path as [PathCodeOwnerStatusInfo](#path-code-owner-status-info) entity. Only set if `change_type` is `DELETED` or `RENAMED`.
 | `new_path_status` | optional | The code owner status for the new path as [PathCodeOwnerStatusInfo](#path-code-owner-status-info) entity. Not set if `change_type` is `DELETED`.
 
+### <a id="general-info"> GeneralInfo
+The `GeneralInfo` entity contains general code owners configuration parameters.
+
+| Field Name       |          | Description |
+| ---------------- | -------- | ----------- |
+| `file_extension` | optional | The file extension that is used for the code
+owner config files in this project. Not set if no file extension is used.
+
 ### <a id="path-code-owner-status-info"> PathCodeOwnerStatusInfo
 The `PathCodeOwnerStatusInfo` entity describes the code owner status for a path
 in a change.