Merge "PathCodeOwners: Skip resolving global imports that will be ignored"
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInput.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInput.java
index 2c0128a..0cd47fa 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInput.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInput.java
@@ -71,6 +71,9 @@
   /** Emails of users that should be code owners globally across all branches. */
   public List<String> globalCodeOwners;
 
+  /** Emails of users that should be exempted from requiring code owner approvals. */
+  public List<String> exemptedUsers;
+
   /** Strategy that defines for merge commits which files require code owner approvals. */
   public MergeCommitStrategy mergeCommitStrategy;
 
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
index 4c41510..8361d9b 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
@@ -238,12 +238,25 @@
       CodeOwnersPluginConfigSnapshot codeOwnersConfig =
           codeOwnersPluginConfiguration.getProjectConfig(changeNotes.getProjectName());
 
-      if (codeOwnersConfig.arePureRevertsExempted() && isPureRevert(changeNotes)) {
+      Account.Id patchSetUploader = changeNotes.getCurrentPatchSet().uploader();
+      ImmutableSet<Account.Id> exemptedAccounts = codeOwnersConfig.getExemptedAccounts();
+      logger.atFine().log("exemptedAccounts = %s", exemptedAccounts);
+      if (exemptedAccounts.contains(patchSetUploader)) {
+        logger.atFine().log(
+            "patch set uploader %d is exempted from requiring code owner approvals",
+            patchSetUploader.get());
+        return getAllPathsAsApproved(changeNotes, changeNotes.getCurrentPatchSet());
+      }
+
+      boolean arePureRevertsExempted = codeOwnersConfig.arePureRevertsExempted();
+      logger.atFine().log("arePureRevertsExempted = %s", arePureRevertsExempted);
+      if (arePureRevertsExempted && isPureRevert(changeNotes)) {
+        logger.atFine().log(
+            "change is a pure revert and is exempted from requiring code owner approvals");
         return getAllPathsAsApproved(changeNotes, changeNotes.getCurrentPatchSet());
       }
 
       boolean enableImplicitApprovalFromUploader = codeOwnersConfig.areImplicitApprovalsEnabled();
-      Account.Id patchSetUploader = changeNotes.getCurrentPatchSet().uploader();
       logger.atFine().log(
           "patchSetUploader = %d, implicit approval from uploader is %s",
           patchSetUploader.get(), enableImplicitApprovalFromUploader ? "enabled" : "disabled");
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshot.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshot.java
index 666b2c1..72747a6 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshot.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshot.java
@@ -21,25 +21,34 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnersInternalServerErrorException;
 import com.google.gerrit.plugins.codeowners.backend.EnableImplicitApprovals;
 import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.plugins.codeowners.common.CodeOwnerConfigValidationPolicy;
 import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 
 /** Snapshot of the code-owners plugin configuration for one project. */
@@ -53,6 +62,7 @@
   private final String pluginName;
   private final PluginConfigFactory pluginConfigFactory;
   private final ProjectCache projectCache;
+  private final AccountResolver accountResolver;
   private final BackendConfig backendConfig;
   private final GeneralConfig generalConfig;
   private final OverrideApprovalConfig overrideApprovalConfig;
@@ -61,11 +71,14 @@
   private final Project.NameKey projectName;
   private final Config pluginConfig;
 
+  @Nullable private ImmutableSet<Account.Id> exemptedAccounts;
+
   @Inject
   CodeOwnersPluginConfigSnapshot(
       @PluginName String pluginName,
       PluginConfigFactory pluginConfigFactory,
       ProjectCache projectCache,
+      AccountResolver accountResolver,
       BackendConfig backendConfig,
       GeneralConfig generalConfig,
       OverrideApprovalConfig overrideApprovalConfig,
@@ -75,6 +88,7 @@
     this.pluginName = pluginName;
     this.pluginConfigFactory = pluginConfigFactory;
     this.projectCache = projectCache;
+    this.accountResolver = accountResolver;
     this.backendConfig = backendConfig;
     this.generalConfig = generalConfig;
     this.overrideApprovalConfig = overrideApprovalConfig;
@@ -148,6 +162,47 @@
     return generalConfig.getGlobalCodeOwners(pluginConfig);
   }
 
+  /** Gets the accounts that are exempted from requiring code owner approvals. */
+  public ImmutableSet<Account.Id> getExemptedAccounts() {
+    if (exemptedAccounts == null) {
+      exemptedAccounts = lookupExemptedAccounts();
+    }
+    return exemptedAccounts;
+  }
+
+  private ImmutableSet<Account.Id> lookupExemptedAccounts() {
+    ImmutableSet.Builder<Account.Id> exemptedAccounts = ImmutableSet.builder();
+    for (String exemptedUser : generalConfig.getExemptedUsers(pluginConfig)) {
+      try {
+        AccountState accountState = accountResolver.resolve(exemptedUser).asUnique();
+
+        // We only support specifying exempted users by email, if another account identifier (full
+        // name, account ID, etc.) was used in the config we want to ignore it. Hence after looking
+        // up the account we check that the identifier from the config was indeed an email of the
+        // account.
+        if (!ExternalId.getEmails(accountState.externalIds())
+            .anyMatch(email -> email.equals(exemptedUser))) {
+          logger.atWarning().log(
+              "Ignoring exempted user %s for project %s: not an email", exemptedUser, projectName);
+          continue;
+        }
+
+        exemptedAccounts.add(accountState.account().id());
+      } catch (UnresolvableAccountException e) {
+        logger.atWarning().log(
+            "Ignoring exempted user %s for project %s: %s",
+            exemptedUser, projectName, e.getMessage());
+      } catch (IOException | ConfigInvalidException e) {
+        throw new CodeOwnersInternalServerErrorException(
+            String.format(
+                "Failed to resolve exempted user %s on project %s", exemptedUser, projectName),
+            e);
+      }
+    }
+
+    return exemptedAccounts.build();
+  }
+
   /** Gets the override info URL that is configured. */
   public Optional<String> getOverrideInfoUrl() {
     return generalConfig.getOverrideInfoUrl(pluginConfig);
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
index 28c8456..ddeec46 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
@@ -66,6 +66,7 @@
   public static final String KEY_MAX_PATHS_IN_CHANGE_MESSAGES = "maxPathsInChangeMessages";
   public static final String KEY_MERGE_COMMIT_STRATEGY = "mergeCommitStrategy";
   public static final String KEY_GLOBAL_CODE_OWNER = "globalCodeOwner";
+  public static final String KEY_EXEMPTED_USER = "exemptedUser";
   public static final String KEY_ENABLE_IMPLICIT_APPROVALS = "enableImplicitApprovals";
   public static final String KEY_OVERRIDE_INFO_URL = "overrideInfoUrl";
   public static final int DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES = 100;
@@ -571,6 +572,32 @@
   }
 
   /**
+   * Gets the users which are exempted from requiring code owner approvals.
+   *
+   * <p>If a user is exempted from requiring code owner approvals changes that are uploaded by this
+   * user are automatically code-owner approved.
+   *
+   * @param pluginConfig the plugin config from which the exempted users should be read.
+   * @return the users which are exempted from requiring code owner approvals
+   */
+  ImmutableSet<String> getExemptedUsers(Config pluginConfig) {
+    requireNonNull(pluginConfig, "pluginConfig");
+
+    if (pluginConfig.getString(SECTION_CODE_OWNERS, /* subsection= */ null, KEY_EXEMPTED_USER)
+        != null) {
+      return Arrays.stream(
+              pluginConfig.getStringList(
+                  SECTION_CODE_OWNERS, /* subsection= */ null, KEY_EXEMPTED_USER))
+          .filter(value -> !value.trim().isEmpty())
+          .collect(toImmutableSet());
+    }
+
+    return Arrays.stream(pluginConfigFromGerritConfig.getStringList(KEY_EXEMPTED_USER))
+        .filter(value -> !value.trim().isEmpty())
+        .collect(toImmutableSet());
+  }
+
+  /**
    * Gets an URL that leads to an information page about overrides.
    *
    * <p>The URL is retrieved from the given plugin config, with fallback to the {@code
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/PutCodeOwnerProjectConfig.java b/java/com/google/gerrit/plugins/codeowners/restapi/PutCodeOwnerProjectConfig.java
index 308c3ac..ed130c8 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/PutCodeOwnerProjectConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/PutCodeOwnerProjectConfig.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_IMPLICIT_APPROVALS;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_EXEMPTED_USER;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_EXEMPT_PURE_REVERTS;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_FALLBACK_CODE_OWNERS;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_FILE_EXTENSION;
@@ -169,6 +170,11 @@
             input.globalCodeOwners);
       }
 
+      if (input.exemptedUsers != null) {
+        codeOwnersConfig.setStringList(
+            SECTION_CODE_OWNERS, /* subsection= */ null, KEY_EXEMPTED_USER, input.exemptedUsers);
+      }
+
       if (input.mergeCommitStrategy != null) {
         codeOwnersConfig.setEnum(
             SECTION_CODE_OWNERS,
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/PutCodeOwnerProjectConfigIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/PutCodeOwnerProjectConfigIT.java
index d8eefd3..655b45e 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/PutCodeOwnerProjectConfigIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/PutCodeOwnerProjectConfigIT.java
@@ -23,6 +23,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -340,6 +341,25 @@
   }
 
   @Test
+  public void setExemptedUsers() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getExemptedAccounts())
+        .isEmpty();
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.exemptedUsers = ImmutableList.of(user.email(), user2.email());
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getExemptedAccounts())
+        .containsExactly(user.id(), user2.id());
+
+    input.exemptedUsers = ImmutableList.of();
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getExemptedAccounts())
+        .isEmpty();
+  }
+
+  @Test
   public void setMergeCommitStrategy() throws Exception {
     assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getMergeCommitStrategy())
         .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java
index 27167c6..f2c55e4 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java
@@ -2001,6 +2001,47 @@
         .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
   }
 
+  @Test
+  @GerritConfig(name = "plugin.code-owners.exemptedUser", value = "exempted-user@example.com")
+  public void changeUploadedByExemptedUserIsApproved() throws Exception {
+    TestAccount exemptedUser =
+        accountCreator.create(
+            "exemptedUser", "exempted-user@example.com", "Exempted User", /* displayName= */ null);
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(exemptedUser, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Check that the file is approved since the uploader is exempted from requiring code owner
+    // approvals.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+
+    // Amend the change by another user, so that the other non-exempted user becomes the last
+    // uploader.
+    amendChange(user, changeId);
+
+    // Check that the file is no longer approved since the uploader is not exempted from requiring
+    // code owner approvals.
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
   private ChangeNotes getChangeNotes(String changeId) throws Exception {
     return changeNotesFactory.create(project, Change.id(gApi.changes().id(changeId).get()._number));
   }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshotTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshotTest.java
index 997eb19..529cbd4 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshotTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshotTest.java
@@ -22,6 +22,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
@@ -170,6 +171,163 @@
   }
 
   @Test
+  public void getExemptedAccountsIfNoneIsConfigured() throws Exception {
+    assertThat(cfgSnapshot().getExemptedAccounts()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.exemptedUser",
+      values = {"exempted-user-1@example.com", "exempted-user-2@example.com"})
+  public void getExemptedAccountsIfNoneIsConfiguredOnProjectLevel() throws Exception {
+    TestAccount exemptedUser1 =
+        accountCreator.create(
+            "exemptedUser1",
+            "exempted-user-1@example.com",
+            "Exempted User 1",
+            /* displayName= */ null);
+    TestAccount exemptedUser2 =
+        accountCreator.create(
+            "exemptedUser2",
+            "exempted-user-2@example.com",
+            "Exempted User 2",
+            /* displayName= */ null);
+    assertThat(cfgSnapshot().getExemptedAccounts())
+        .containsExactly(exemptedUser1.id(), exemptedUser2.id());
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.exemptedUser",
+      values = {"exempted-user-1@example.com", "exempted-user-2@example.com"})
+  public void exemptedAccountsOnProjectLevelOverrideGlobalExemptedAcounts() throws Exception {
+    accountCreator.create(
+        "exemptedUser1", "exempted-user-1@example.com", "Exempted User 1", /* displayName= */ null);
+    accountCreator.create(
+        "exemptedUser2", "exempted-user-2@example.com", "Exempted User 2", /* displayName= */ null);
+    TestAccount exemptedUser3 =
+        accountCreator.create(
+            "exemptedUser3",
+            "exempted-user-3@example.com",
+            "Exempted User 3",
+            /* displayName= */ null);
+    TestAccount exemptedUser4 =
+        accountCreator.create(
+            "exemptedUser4",
+            "exempted-user-4@example.com",
+            "Exempted User 4",
+            /* displayName= */ null);
+    configureExemptedUsers(allProjects, exemptedUser3.email(), exemptedUser4.email());
+    assertThat(cfgSnapshot().getExemptedAccounts())
+        .containsExactly(exemptedUser3.id(), exemptedUser4.id());
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.exemptedUser",
+      values = {"exempted-user-1@example.com", "exempted-user-2@example.com"})
+  public void exemptedAccountsAreInheritedFromParentProject() throws Exception {
+    accountCreator.create(
+        "exemptedUser1", "exempted-user-1@example.com", "Exempted User 1", /* displayName= */ null);
+    accountCreator.create(
+        "exemptedUser2", "exempted-user-2@example.com", "Exempted User 2", /* displayName= */ null);
+    TestAccount exemptedUser3 =
+        accountCreator.create(
+            "exemptedUser3",
+            "exempted-user-3@example.com",
+            "Exempted User 3",
+            /* displayName= */ null);
+    TestAccount exemptedUser4 =
+        accountCreator.create(
+            "exemptedUser4",
+            "exempted-user-4@example.com",
+            "Exempted User 4",
+            /* displayName= */ null);
+    configureExemptedUsers(allProjects, exemptedUser3.email(), exemptedUser4.email());
+    assertThat(cfgSnapshot().getExemptedAccounts())
+        .containsExactly(exemptedUser3.id(), exemptedUser4.id());
+  }
+
+  @Test
+  public void inheritedExemptedAccountsCanBeOverridden() throws Exception {
+    TestAccount exemptedUser1 =
+        accountCreator.create(
+            "exemptedUser1",
+            "exempted-user-1@example.com",
+            "Exempted User 1",
+            /* displayName= */ null);
+    TestAccount exemptedUser2 =
+        accountCreator.create(
+            "exemptedUser2",
+            "exempted-user-2@example.com",
+            "Exempted User 2",
+            /* displayName= */ null);
+    TestAccount exemptedUser3 =
+        accountCreator.create(
+            "exemptedUser3",
+            "exempted-user-3@example.com",
+            "Exempted User 3",
+            /* displayName= */ null);
+    TestAccount exemptedUser4 =
+        accountCreator.create(
+            "exemptedUser4",
+            "exempted-user-4@example.com",
+            "Exempted User 4",
+            /* displayName= */ null);
+    configureExemptedUsers(allProjects, exemptedUser1.email(), exemptedUser2.email());
+    configureExemptedUsers(project, exemptedUser3.email(), exemptedUser4.email());
+    assertThat(cfgSnapshot().getExemptedAccounts())
+        .containsExactly(exemptedUser3.id(), exemptedUser4.id());
+  }
+
+  @Test
+  public void inheritedExemptedAccountsCanBeRemoved() throws Exception {
+    TestAccount exemptedUser1 =
+        accountCreator.create(
+            "exemptedUser1",
+            "exempted-user-1@example.com",
+            "Exempted User 1",
+            /* displayName= */ null);
+    TestAccount exemptedUser2 =
+        accountCreator.create(
+            "exemptedUser2",
+            "exempted-user-2@example.com",
+            "Exempted User 2",
+            /* displayName= */ null);
+    configureExemptedUsers(allProjects, exemptedUser1.email(), exemptedUser2.email());
+    configureExemptedUsers(project, "");
+    assertThat(cfgSnapshot().getExemptedAccounts()).isEmpty();
+  }
+
+  @Test
+  public void nonResolvableExemptedAccountsAreIgnored() throws Exception {
+    TestAccount exemptedUser =
+        accountCreator.create(
+            "exemptedUser", "exempted-user@example.com", "Exempted User", /* displayName= */ null);
+    configureExemptedUsers(project, exemptedUser.email(), "non-resolveable@example.com");
+    assertThat(cfgSnapshot().getExemptedAccounts()).containsExactly(exemptedUser.id());
+  }
+
+  @Test
+  public void exemptedAccountsByIdAreIgnored() throws Exception {
+    TestAccount exemptedUser1 =
+        accountCreator.create(
+            "exemptedUser1",
+            "exempted-user-1@example.com",
+            "Exempted User 1",
+            /* displayName= */ null);
+    TestAccount exemptedUser2 =
+        accountCreator.create(
+            "exemptedUser2",
+            "exempted-user-2@example.com",
+            "Exempted User 2",
+            /* displayName= */ null);
+    configureExemptedUsers(
+        project, exemptedUser1.email(), Integer.toString(exemptedUser2.id().get()));
+    assertThat(cfgSnapshot().getExemptedAccounts()).containsExactly(exemptedUser1.id());
+  }
+
+  @Test
   public void getMaxPathsInChangeMessagesIfNoneIsConfigured() throws Exception {
     assertThat(cfgSnapshot().getMaxPathsInChangeMessages())
         .isEqualTo(GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
@@ -780,6 +938,15 @@
         fallbackCodeOwners.name());
   }
 
+  private void configureExemptedUsers(Project.NameKey project, String... exemptedUsers)
+      throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_EXEMPTED_USER,
+        ImmutableList.copyOf(exemptedUsers));
+  }
+
   private void configureMaxPathsInChangeMessages(
       Project.NameKey project, int maxPathsInChangeMessages) throws Exception {
     setCodeOwnersConfig(
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
index b5716ee..ed66d23 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_IMPLICIT_APPROVALS;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_EXEMPTED_USER;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_EXEMPT_PURE_REVERTS;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_FALLBACK_CODE_OWNERS;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_FILE_EXTENSION;
@@ -713,6 +714,51 @@
   }
 
   @Test
+  public void cannotGetExemptedUsersForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getExemptedUsers(/* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noExemptedUsers() throws Exception {
+    assertThat(generalConfig.getExemptedUsers(new Config())).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.exemptedUser",
+      values = {"bot1@example.com", "bot2@example.com"})
+  public void exemptedUsersAreRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+      throws Exception {
+    assertThat(generalConfig.getExemptedUsers(new Config()))
+        .containsExactly("bot1@example.com", "bot2@example.com");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.exemptedUser",
+      values = {"bot1@example.com", "bot2@example.com"})
+  public void exemptedUsersInPluginConfigOverrideExemptedUsersInGerritConfig() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS, /* subsection= */ null, KEY_EXEMPTED_USER, "bot3@example.com");
+    assertThat(generalConfig.getExemptedUsers(cfg)).containsExactly("bot3@example.com");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.exemptedUsers",
+      values = {"bot1@example.com", "bot2@example.com"})
+  public void inheritedExemptedUsersCanBeRemovedOnProjectLevel() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, /* subsection= */ null, KEY_EXEMPTED_USER, "");
+    assertThat(generalConfig.getExemptedUsers(cfg)).isEmpty();
+  }
+
+  @Test
   public void cannotGetOverrideInfoUrlForNullPluginConfig() throws Exception {
     NullPointerException npe =
         assertThrows(
diff --git a/resources/Documentation/config.md b/resources/Documentation/config.md
index 6d45d59..dc5ac94 100644
--- a/resources/Documentation/config.md
+++ b/resources/Documentation/config.md
@@ -153,6 +153,17 @@
         `@PLUGIN@.config`.\
         By default unset (no global code owners).
 
+<a id="pluginCodeOwnersExemptedUser">plugin.@PLUGIN@.exemptedUser</a>
+:       The email of a user that should be exempted from requiring code owner
+        approvals.\
+        If a user is exempted from requiring code owner approvals changes that
+        are uploaded by this user are automatically code-owner approved.\
+        Can be specified multiple times to exempt multiple users.\
+        Can be overridden per project by setting
+        [codeOwners.exemptedUser](#codeOwnersExemptedUser) in
+        `@PLUGIN@.config`.\
+        By default unset (no exempted users).
+
 <a id="pluginCodeOwnersReadOnly">plugin.@PLUGIN@.readOnly</a>
 :       Whether code owner config files are read-only.\
         Can be overridden per project by setting
@@ -541,6 +552,20 @@
         [plugin.@PLUGIN@.globalCodeOwner](#pluginCodeOwnersGlobalCodeOwner) in
         `gerrit.config` is used.
 
+<a id="codeOwnersExemptedUser">codeOwners.exemptedUser</a>
+:       The email of a user that should be exempted from requiring code owner
+        approvals.\
+        If a user is exempted from requiring code owner approvals changes that
+        are uploaded by this user are automatically code-owner approved.\
+        Can be specified multiple times to exempt multiple users.\
+        Overrides the global setting
+        [plugin.@PLUGIN@.exemptedUser](#pluginCodeOwnersExemptedUser) in
+        `gerrit.config` and the `codeOwners.exemptedUser` setting from parent
+        projects.\
+        If not set, the global setting
+        [plugin.@PLUGIN@.exemptedUser](#pluginCodeOwnersExemptedUser) in
+        `gerrit.config` is used.
+
 <a id="codeOwnersReadOnly">codeOwners.readOnly</a>
 :       Whether code owner config files are read-only.\
         Overrides the global setting
diff --git a/resources/Documentation/rest-api.md b/resources/Documentation/rest-api.md
index 9d4d3a8..c0e608b 100644
--- a/resources/Documentation/rest-api.md
+++ b/resources/Documentation/rest-api.md
@@ -886,6 +886,7 @@
 | `override_approvals` | optional | The approvals that count as override for the code owners submit check. Must be specified in the format "\<label-name\>+\<label-value\>".
 | `fallback_code_owners` | optional | Policy that controls who should own paths that have no code owners defined. Possible values are: `NONE`: Paths for which no code owners are defined are owned by no one. `PROJECT_OWNERS`: Paths for which no code owners are defined are owned by the project owners. `ALL_USERS`: Paths for which no code owners are defined are owned by all users.
 | `global_code_owners` | optional | List of emails of users that should be code owners globally across all branches.
+| `exempted_users` | optional | List of emails of users that should be exempted from requiring code owners approvals.
 | `merge_commit_strategy` | optional | Strategy that defines for merge commits which files require code owner approvals. Can be `ALL_CHANGED_FILES` or `FILES_WITH_CONFLICT_RESOLUTION` (see [mergeCommitStrategy](config.html#pluginCodeOwnersMergeCommitStrategy) for an explanation of these values).
 | `implicit_approvals` | optional | Whether an implicit code owner approval from the last uploader is assumed.
 | `override_info_url` | optional | URL for a page that provides project/host-specific information about how to request a code owner override.