Add REST endpoint to rename an email in code owner config files of one branch

This REST endpoint updates code owner config files programmatically. If
it would update the emails by parsing, updating and then formatting code
owner config files, comments in the code owner config files would be
lost (since they are not read and remembered during parsing). Instead
this REST endpoint replaces emails in code owner config files on text
level by doing a string replacement. To match email strings in code
owner config files correctly, we need backend specific knowledge about
the syntax, e.g. which characters can appear before and after an email.
Doing a simple string replacement (replace oldEmail-string with
newEmail-string) would wrongly affect emails that contain the oldEmail
as a substring.

This REST endpoint cannot be used to assign the code ownerships of one
user to another user, but requires that the old and new emails belong to
the same account.

This REST endpoint requires the caller to be project owner or have
direct push permissions to the branch.

It is intended that this REST endpoint is used by a service or bot that
takes care of updating code owner config files when a user changes their
email, as the email update potentially needs to be done in all repos and
all branches.

Change-Id: I3d930b03503dcc6a212736e37ea9ab00b9e6952d
Signed-off-by: Edwin Kempin <ekempin@google.com>
diff --git a/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwners.java
index 2b81afb..82fb351 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwners.java
@@ -66,6 +66,10 @@
     public abstract List<String> paths() throws RestApiException;
   }
 
+  /** Renames an email in the code owner config files of the branch. */
+  RenameEmailResultInfo renameEmailInCodeOwnerConfigFiles(RenameEmailInput input)
+      throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -80,5 +84,11 @@
     public CodeOwnerConfigFilesRequest codeOwnerConfigFiles() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public RenameEmailResultInfo renameEmailInCodeOwnerConfigFiles(RenameEmailInput input)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwnersImpl.java b/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwnersImpl.java
index aacc3e2..f6e4804 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwnersImpl.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwnersImpl.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnerBranchConfig;
 import com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnerConfigFiles;
+import com.google.gerrit.plugins.codeowners.restapi.RenameEmail;
 import com.google.gerrit.server.project.BranchResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -33,15 +34,18 @@
 
   private final GetCodeOwnerBranchConfig getCodeOwnerBranchConfig;
   private final Provider<GetCodeOwnerConfigFiles> getCodeOwnerConfigFilesProvider;
+  private final RenameEmail renameEmail;
   private final BranchResource branchResource;
 
   @Inject
   public BranchCodeOwnersImpl(
       GetCodeOwnerBranchConfig getCodeOwnerBranchConfig,
       Provider<GetCodeOwnerConfigFiles> getCodeOwnerConfigFilesProvider,
+      RenameEmail renameEmail,
       @Assisted BranchResource branchResource) {
     this.getCodeOwnerConfigFilesProvider = getCodeOwnerConfigFilesProvider;
     this.getCodeOwnerBranchConfig = getCodeOwnerBranchConfig;
+    this.renameEmail = renameEmail;
     this.branchResource = branchResource;
   }
 
@@ -66,4 +70,14 @@
       }
     };
   }
+
+  @Override
+  public RenameEmailResultInfo renameEmailInCodeOwnerConfigFiles(RenameEmailInput input)
+      throws RestApiException {
+    try {
+      return renameEmail.apply(branchResource, input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rename email", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/RenameEmailInput.java b/java/com/google/gerrit/plugins/codeowners/api/RenameEmailInput.java
new file mode 100644
index 0000000..cf38e59
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/api/RenameEmailInput.java
@@ -0,0 +1,32 @@
+// 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;
+
+/**
+ * The input for the {@link com.google.gerrit.plugins.codeowners.restapi.RenameEmail} REST endpoint.
+ */
+public class RenameEmailInput {
+  /**
+   * Optional commit message that should be used for the commit that renames the email in the code
+   * owner config files.
+   */
+  public String message;
+
+  /** The old email that should be replaced with the new email. */
+  public String oldEmail;
+
+  /** The new email that should be used to replace the old email. */
+  public String newEmail;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/api/RenameEmailResultInfo.java b/java/com/google/gerrit/plugins/codeowners/api/RenameEmailResultInfo.java
new file mode 100644
index 0000000..6400734
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/api/RenameEmailResultInfo.java
@@ -0,0 +1,25 @@
+// 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;
+
+import com.google.gerrit.extensions.common.CommitInfo;
+
+/**
+ * The result of the {@link com.google.gerrit.plugins.codeowners.restapi.RenameEmail} REST endpoint.
+ */
+public class RenameEmailResultInfo {
+  /** The commit that did the email rename, not set if no update was performed. */
+  public CommitInfo commit;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackend.java
index 0f762e1..e0d9e17 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackend.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.server.IdentifiedUser;
 import java.nio.file.Path;
 import java.util.Optional;
@@ -118,4 +119,19 @@
   default Optional<PathExpressionMatcher> getPathExpressionMatcher() {
     return Optional.empty();
   }
+
+  /**
+   * Replaces the old email in the given code owner config file content with the new email.
+   *
+   * @param codeOwnerConfigFileContent the code owner config file content in which the old email
+   *     should be replaced with the new email
+   * @param oldEmail the email that should be replaced by the new email
+   * @param newEmail the email that should replace the old email
+   * @return the updated content
+   * @throws NotImplementedException if the backend doesn't support replacing emails in code owner
+   *     config files
+   */
+  default String replaceEmail(String codeOwnerConfigFileContent, String oldEmail, String newEmail) {
+    throw new NotImplementedException();
+  }
 }
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 0aa9ca8..ab08201 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
@@ -63,4 +63,10 @@
   public Optional<PathExpressionMatcher> getPathExpressionMatcher() {
     return Optional.of(GlobMatcher.INSTANCE);
   }
+
+  @Override
+  public String replaceEmail(String codeOwnerConfigFileContent, String oldEmail, String newEmail) {
+    return FindOwnersCodeOwnerConfigParser.replaceEmail(
+        codeOwnerConfigFileContent, oldEmail, newEmail);
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParser.java b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParser.java
index 7e8fff8..561c3b1 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParser.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParser.java
@@ -20,6 +20,7 @@
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
@@ -77,6 +78,12 @@
   // Artifical owner token for "set noparent" when used in per-file.
   private static final String TOK_SET_NOPARENT = "set noparent";
 
+  /**
+   * Any Unicode linebreak sequence, is equivalent to {@code
+   * \u000D\u000A|[\u000A\u000B\u000C\u000D\u0085\u2028\u2029]}.
+   */
+  private static final String LINEBREAK_MATCHER = "\\R";
+
   @Override
   public CodeOwnerConfig parse(
       ObjectId revision, CodeOwnerConfig.Key codeOwnerConfigKey, String codeOwnerConfigAsString)
@@ -98,6 +105,35 @@
     return Formatter.formatAsString(requireNonNull(codeOwnerConfig, "codeOwnerConfig"));
   }
 
+  public static String replaceEmail(
+      String codeOwnerConfigFileContent, String oldEmail, String newEmail) {
+    requireNonNull(codeOwnerConfigFileContent, "codeOwnerConfigFileContent");
+    requireNonNull(oldEmail, "oldEmail");
+    requireNonNull(newEmail, "newEmail");
+
+    String charsThatCanAppearBeforeOrAfterEmail = "[\\s=,#]";
+    Pattern pattern =
+        Pattern.compile(
+            "(^|.*"
+                + charsThatCanAppearBeforeOrAfterEmail
+                + "+)"
+                + "("
+                + Pattern.quote(oldEmail)
+                + ")"
+                + "($|"
+                + charsThatCanAppearBeforeOrAfterEmail
+                + "+.*)");
+
+    List<String> updatedLines = new ArrayList<>();
+    for (String line : Splitter.onPattern(LINEBREAK_MATCHER).split(codeOwnerConfigFileContent)) {
+      while (pattern.matcher(line).matches()) {
+        line = pattern.matcher(line).replaceFirst("$1" + newEmail + "$3");
+      }
+      updatedLines.add(line);
+    }
+    return Joiner.on("\n").join(updatedLines);
+  }
+
   private static class Parser implements ValidationError.Sink {
     private static final String COMMA = "[\\s]*,[\\s]*";
 
@@ -158,7 +194,7 @@
       CodeOwnerSet.Builder globalCodeOwnerSetBuilder = CodeOwnerSet.builder();
       List<CodeOwnerSet> perFileCodeOwnerSet = new ArrayList<>();
 
-      for (String line : Splitter.onPattern("\\R").split(codeOwnerConfigAsString)) {
+      for (String line : Splitter.onPattern(LINEBREAK_MATCHER).split(codeOwnerConfigAsString)) {
         parseLine(codeOwnerConfigBuilder, globalCodeOwnerSetBuilder, perFileCodeOwnerSet, line);
       }
 
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java b/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java
new file mode 100644
index 0000000..8df9a34
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java
@@ -0,0 +1,202 @@
+// 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.restapi;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.plugins.codeowners.api.RenameEmailInput;
+import com.google.gerrit.plugins.codeowners.api.RenameEmailResultInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwner;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigFileUpdateScanner;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
+import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.BranchResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+@Singleton
+public class RenameEmail implements RestModifyView<BranchResource, RenameEmailInput> {
+  @VisibleForTesting
+  public static String DEFAULT_COMMIT_MESSAGE = "Rename email in code owner config files";
+
+  private final Provider<CurrentUser> currentUser;
+  private final PermissionBackend permissionBackend;
+  private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+  private final CodeOwnerResolver codeOwnerResolver;
+  private final CodeOwnerConfigFileUpdateScanner codeOwnerConfigFileUpdateScanner;
+
+  @Inject
+  public RenameEmail(
+      Provider<CurrentUser> currentUser,
+      PermissionBackend permissionBackend,
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+      CodeOwnerResolver codeOwnerResolver,
+      CodeOwnerConfigFileUpdateScanner codeOwnerConfigFileUpdateScanner) {
+    this.currentUser = currentUser;
+    this.permissionBackend = permissionBackend;
+    this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
+    this.codeOwnerResolver = codeOwnerResolver;
+    this.codeOwnerConfigFileUpdateScanner = codeOwnerConfigFileUpdateScanner;
+  }
+
+  @Override
+  public Response<RenameEmailResultInfo> apply(
+      BranchResource branchResource, RenameEmailInput input)
+      throws AuthException, BadRequestException, ResourceConflictException,
+          MethodNotAllowedException, UnprocessableEntityException, PermissionBackendException,
+          IOException {
+    if (!currentUser.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    // caller needs to be project owner or have direct push permissions for the branch
+    if (!permissionBackend
+        .currentUser()
+        .project(branchResource.getNameKey())
+        .test(ProjectPermission.WRITE_CONFIG)) {
+      permissionBackend
+          .currentUser()
+          .ref(branchResource.getBranchKey())
+          .check(RefPermission.UPDATE);
+    }
+
+    validateInput(input);
+
+    CodeOwnerBackend codeOwnerBackend =
+        codeOwnersPluginConfiguration.getBackend(branchResource.getBranchKey());
+
+    Account.Id accountOwningOldEmail = resolveEmail(input.oldEmail);
+    Account.Id accountOwningNewEmail = resolveEmail(input.newEmail);
+    if (!accountOwningOldEmail.equals(accountOwningNewEmail)) {
+      throw new BadRequestException(
+          String.format(
+              "emails must belong to the same account"
+                  + " (old email %s is owned by account %d, new email %s is owned by account %d)",
+              input.oldEmail,
+              accountOwningOldEmail.get(),
+              input.newEmail,
+              accountOwningNewEmail.get()));
+    }
+
+    String inputMessage = Strings.nullToEmpty(input.message).trim();
+    String commitMessage = !inputMessage.isEmpty() ? inputMessage : DEFAULT_COMMIT_MESSAGE;
+
+    try {
+      Optional<RevCommit> commitId =
+          codeOwnerConfigFileUpdateScanner.update(
+              branchResource.getBranchKey(),
+              commitMessage,
+              (codeOwnerConfigFilePath, codeOwnerConfigFileContent) ->
+                  renameEmailInCodeOwnerConfig(
+                      codeOwnerBackend,
+                      codeOwnerConfigFileContent,
+                      input.oldEmail,
+                      input.newEmail));
+
+      RenameEmailResultInfo result = new RenameEmailResultInfo();
+      if (commitId.isPresent()) {
+        result.commit = CommitUtil.toCommitInfo(commitId.get());
+      }
+      return Response.ok(result);
+    } catch (NotImplementedException e) {
+      throw new MethodNotAllowedException(
+          String.format(
+              "rename email not supported by %s backend",
+              CodeOwnerBackendId.getBackendId(codeOwnerBackend.getClass())),
+          e);
+    }
+  }
+
+  private void validateInput(RenameEmailInput input) throws BadRequestException {
+    if (input.oldEmail == null) {
+      throw new BadRequestException("old email is required");
+    }
+    if (input.newEmail == null) {
+      throw new BadRequestException("new email is required");
+    }
+    if (input.oldEmail.equals(input.newEmail)) {
+      throw new BadRequestException("old and new email must differ");
+    }
+  }
+
+  private Account.Id resolveEmail(String email)
+      throws ResourceConflictException, UnprocessableEntityException {
+    requireNonNull(email, "email");
+
+    ImmutableSet<CodeOwner> codeOwners =
+        codeOwnerResolver.resolve(CodeOwnerReference.create(email)).collect(toImmutableSet());
+    if (codeOwners.isEmpty()) {
+      throw new UnprocessableEntityException(String.format("cannot resolve email %s", email));
+    }
+    if (codeOwners.size() > 1) {
+      throw new ResourceConflictException(String.format("email %s is ambigious", email));
+    }
+    return Iterables.getOnlyElement(codeOwners).accountId();
+  }
+
+  /**
+   * Renames an email in the given code owner config.
+   *
+   * @param codeOwnerBackend the code owner backend that is being used
+   * @param codeOwnerConfigFileContent the content of the code owner config file
+   * @param oldEmail the old email that should be replaced by the new email
+   * @param newEmail the new email that should replace the old email
+   * @return the updated code owner config file content if an update was performed, {@link
+   *     Optional#empty()} if no update was done
+   */
+  private Optional<String> renameEmailInCodeOwnerConfig(
+      CodeOwnerBackend codeOwnerBackend,
+      String codeOwnerConfigFileContent,
+      String oldEmail,
+      String newEmail) {
+    requireNonNull(codeOwnerConfigFileContent, "codeOwnerConfigFileContent");
+    requireNonNull(oldEmail, "oldEmail");
+    requireNonNull(newEmail, "newEmail");
+
+    String updatedCodeOwnerConfigFileContent =
+        codeOwnerBackend.replaceEmail(codeOwnerConfigFileContent, oldEmail, newEmail);
+    if (codeOwnerConfigFileContent.equals(updatedCodeOwnerConfigFileContent)) {
+      return Optional.empty();
+    }
+    return Optional.of(updatedCodeOwnerConfigFileContent);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java b/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java
index 38fe259..03d8858 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java
@@ -31,6 +31,7 @@
         .to(GetCodeOwnerConfigForPathInBranch.class);
     get(BRANCH_KIND, "code_owners.config_files").to(GetCodeOwnerConfigFiles.class);
     get(BRANCH_KIND, "code_owners.branch_config").to(GetCodeOwnerBranchConfig.class);
+    post(BRANCH_KIND, "code_owners.rename").to(RenameEmail.class);
 
     factory(CodeOwnerJson.Factory.class);
     DynamicMap.mapOf(binder(), CodeOwnersInBranchCollection.PathResource.PATH_KIND);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/RenameEmailIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/RenameEmailIT.java
new file mode 100644
index 0000000..1c76917
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/RenameEmailIT.java
@@ -0,0 +1,747 @@
+// 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.acceptance.api;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerConfigSubject.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.api.RenameEmailInput;
+import com.google.gerrit.plugins.codeowners.api.RenameEmailResultInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigFileUpdateScanner;
+import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
+import com.google.gerrit.plugins.codeowners.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.restapi.RenameEmail;
+import com.google.inject.Inject;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Acceptance test for the {@link com.google.gerrit.plugins.codeowners.restapi.RenameEmail} REST
+ * endpoint.
+ *
+ * <p>Further tests for the {@link com.google.gerrit.plugins.codeowners.restapi.RenameEmail} REST
+ * endpoint that require using the REST API are implemented in {@link
+ * com.google.gerrit.plugins.codeowners.acceptance.restapi.RenameEmailRestIT}.
+ */
+public class RenameEmailIT extends AbstractCodeOwnersIT {
+  @Inject private AccountOperations accountOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  private BackendConfig backendConfig;
+  private CodeOwnerConfigFileUpdateScanner codeOwnerConfigFileUpdateScanner;
+
+  @Before
+  public void setup() throws Exception {
+    backendConfig = plugin.getSysInjector().getInstance(BackendConfig.class);
+    codeOwnerConfigFileUpdateScanner =
+        plugin.getSysInjector().getInstance(CodeOwnerConfigFileUpdateScanner.class);
+  }
+
+  @Test
+  public void oldEmailIsRequired() throws Exception {
+    RenameEmailInput input = new RenameEmailInput();
+    input.newEmail = "new@example.com";
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> renameEmail(project, "master", input));
+    assertThat(exception).hasMessageThat().isEqualTo("old email is required");
+  }
+
+  @Test
+  public void newEmailIsRequired() throws Exception {
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = "old@example.com";
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> renameEmail(project, "master", input));
+    assertThat(exception).hasMessageThat().isEqualTo("new email is required");
+  }
+
+  @Test
+  public void oldEmailNotResolvable() throws Exception {
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = "unknown@example.com";
+    input.newEmail = admin.email();
+    UnprocessableEntityException exception =
+        assertThrows(
+            UnprocessableEntityException.class, () -> renameEmail(project, "master", input));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("cannot resolve email %s", input.oldEmail));
+  }
+
+  @Test
+  public void newEmailNotResolvable() throws Exception {
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = admin.email();
+    input.newEmail = "unknown@example.com";
+    UnprocessableEntityException exception =
+        assertThrows(
+            UnprocessableEntityException.class, () -> renameEmail(project, "master", input));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("cannot resolve email %s", input.newEmail));
+  }
+
+  @Test
+  public void emailsMustBelongToSameAccount() throws Exception {
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = admin.email();
+    input.newEmail = user.email();
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> renameEmail(project, "master", input));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "emails must belong to the same account"
+                    + " (old email %s is owned by account %d, new email %s is owned by account %d)",
+                admin.email(), admin.id().get(), user.email(), user.id().get()));
+  }
+
+  @Test
+  public void oldAndNewEmailMustDiffer() throws Exception {
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = admin.email();
+    input.newEmail = admin.email();
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> renameEmail(project, "master", input));
+    assertThat(exception).hasMessageThat().isEqualTo("old and new email must differ");
+  }
+
+  @Test
+  public void renameEmailRequiresDirectPushPermissionsForNonProjectOwner() throws Exception {
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    requestScopeOperations.setApiUser(user.id());
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    AuthException exception =
+        assertThrows(AuthException.class, () -> renameEmail(project, "master", input));
+    assertThat(exception).hasMessageThat().isEqualTo("not permitted: update on refs/heads/master");
+  }
+
+  @Test
+  public void renameEmail_noCodeOwnerConfig() throws Exception {
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNull();
+  }
+
+  @Test
+  public void renameEmail_noUpdateIfEmailIsNotContainedInCodeOwnerConfigs() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNull();
+  }
+
+  @Test
+  public void renameOwnEmailWithDirectPushPermission() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(admin.email())
+            .addCodeOwnerEmail(user.email())
+            .create();
+
+    CodeOwnerConfig.Key codeOwnerConfigKey2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/")
+            .addCodeOwnerEmail(user.email())
+            .create();
+
+    // grant all users direct push permissions
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    requestScopeOperations.setApiUser(user.id());
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey1).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail, admin.email());
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey2).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail);
+  }
+
+  @Test
+  public void renameOtherEmailWithDirectPushPermission() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(admin.email())
+            .addCodeOwnerEmail(user.email())
+            .create();
+
+    CodeOwnerConfig.Key codeOwnerConfigKey2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    // grant all users direct push permissions
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    // Allow all users to see secondary emails.
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allowCapability(GlobalCapability.MODIFY_ACCOUNT).group(REGISTERED_USERS))
+        .update();
+
+    String secondaryEmail = "admin-foo@example.com";
+    accountOperations.account(admin.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    requestScopeOperations.setApiUser(user.id());
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = admin.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey1).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail, user.email());
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey2).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail);
+  }
+
+  @Test
+  public void renameOwnEmailAsProjectOwner() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(user.email())
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    CodeOwnerConfig.Key codeOwnerConfigKey2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    String secondaryEmail = "admin-foo@example.com";
+    accountOperations.account(admin.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = admin.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey1).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail, user.email());
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey2).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail);
+  }
+
+  @Test
+  public void renameOtherEmailAsProjectOwner() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey1 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(user.email())
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    CodeOwnerConfig.Key codeOwnerConfigKey2 =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/")
+            .addCodeOwnerEmail(user.email())
+            .create();
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey1).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail, admin.email());
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey2).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail);
+  }
+
+  @Test
+  public void renameEmail_callingUserBecomesCommitAuthor() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user.email())
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+    assertThat(result.commit.author.email).isEqualTo(admin.email());
+  }
+
+  @Test
+  public void renameEmailWithDefaultCommitMessage() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user.email())
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+    assertThat(result.commit.message).isEqualTo(RenameEmail.DEFAULT_COMMIT_MESSAGE);
+  }
+
+  @Test
+  public void renameEmailWithSpecifiedCommitMessage() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user.email())
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    input.message = "Update email with custom message";
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+    assertThat(result.commit.message).isEqualTo(input.message);
+  }
+
+  @Test
+  public void renameEmail_specifiedCommitMessageIsTrimmed() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user.email())
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    String message = "Update email with custom message";
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    input.message = "  " + message + "\t";
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+    assertThat(result.commit.message).isEqualTo(message);
+  }
+
+  @Test
+  public void renameEmail_lineCommentsArePreserved() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(user.email())
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    // insert some comments
+    codeOwnerConfigFileUpdateScanner.update(
+        BranchNameKey.create(project, "master"),
+        "Insert comments",
+        (codeOwnerConfigFilePath, codeOwnerConfigFileContent) -> {
+          StringBuilder b = new StringBuilder();
+          // insert comment line at the top of the file
+          b.append("# top comment\n");
+
+          String[] lines = codeOwnerConfigFileContent.split("\\n");
+          b.append(lines[0] + "\n");
+
+          // insert comment line in the middle of the file
+          b.append("# middle comment\n");
+
+          for (int n = 1; n < lines.length; n++) {
+            b.append(lines[n] + "\n");
+          }
+
+          // insert comment line at the bottom of the file
+          b.append("# bottom comment\n");
+
+          return Optional.of(b.toString());
+        });
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    renameEmail(project, "master", input);
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail, admin.email());
+
+    // verify that the comments are still present
+    String codeOwnerConfigFileContent =
+        codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getContent();
+    String[] lines = codeOwnerConfigFileContent.split("\\n");
+    assertThat(lines[0]).isEqualTo("# top comment");
+    assertThat(lines[2]).isEqualTo("# middle comment");
+    assertThat(lines[lines.length - 1]).isEqualTo("# bottom comment");
+  }
+
+  @Test
+  public void renameEmail_inlineCommentsArePreserved() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(user.email())
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    // insert some inline comments
+    codeOwnerConfigFileUpdateScanner.update(
+        BranchNameKey.create(project, "master"),
+        "Insert comments",
+        (codeOwnerConfigFilePath, codeOwnerConfigFileContent) -> {
+          StringBuilder b = new StringBuilder();
+          for (String line : codeOwnerConfigFileContent.split("\\n")) {
+            if (line.contains(user.email())) {
+              b.append(line + "# some comment\n");
+              continue;
+            }
+            if (line.contains(admin.email())) {
+              b.append(line + "# other comment\n");
+              continue;
+            }
+            b.append(line + "\n");
+          }
+
+          return Optional.of(b.toString());
+        });
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    renameEmail(project, "master", input);
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail, admin.email());
+
+    // verify that the inline comments are still present
+    String codeOwnerConfigFileContent =
+        codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getContent();
+    for (String line : codeOwnerConfigFileContent.split("\\n")) {
+      if (line.contains(secondaryEmail)) {
+        assertThat(line).endsWith("# some comment");
+      } else if (line.contains(admin.email())) {
+        assertThat(line).endsWith("# other comment");
+      }
+    }
+  }
+
+  @Test
+  public void renameEmail_emailInCommentIsReplaced() throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(user.email())
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    // insert some comments
+    codeOwnerConfigFileUpdateScanner.update(
+        BranchNameKey.create(project, "master"),
+        "Insert comments",
+        (codeOwnerConfigFilePath, codeOwnerConfigFileContent) ->
+            Optional.of("# foo " + user.email() + " bar\n" + codeOwnerConfigFileContent));
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    renameEmail(project, "master", input);
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(secondaryEmail, admin.email());
+
+    // verify that the comments are still present
+    String codeOwnerConfigFileContent =
+        codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getContent();
+    assertThat(codeOwnerConfigFileContent.split("\\n")[0])
+        .endsWith("# foo " + secondaryEmail + " bar");
+  }
+
+  @Test
+  public void renameEmail_emailThatContainsEmailToBeReplacesAsSubstringStaysIntact()
+      throws Exception {
+    // renaming email is not supported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+
+    TestAccount otherUser1 =
+        accountCreator.create(
+            "otherUser1",
+            "foo" + user.email(),
+            "Other User 1",
+            /** displayName = */
+            null);
+    TestAccount otherUser2 =
+        accountCreator.create(
+            "otherUser2",
+            user.email() + "bar",
+            "Other User 2",
+            /** displayName = */
+            null);
+    TestAccount otherUser3 =
+        accountCreator.create(
+            "otherUser3",
+            "foo" + user.email() + "bar",
+            "Other User 3",
+            /** displayName = */
+            null);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(user.email())
+            .addCodeOwnerEmail(otherUser1.email())
+            .addCodeOwnerEmail(otherUser2.email())
+            .addCodeOwnerEmail(otherUser3.email())
+            .create();
+
+    // grant all users direct push permissions
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    String secondaryEmail = "user-new@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    requestScopeOperations.setApiUser(user.id());
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RenameEmailResultInfo result = renameEmail(project, "master", input);
+    assertThat(result.commit).isNotNull();
+
+    assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).get())
+        .hasCodeOwnerSetsThat()
+        .onlyElement()
+        .hasCodeOwnersEmailsThat()
+        .containsExactly(
+            secondaryEmail, otherUser1.email(), otherUser2.email(), otherUser3.email());
+  }
+
+  private RenameEmailResultInfo renameEmail(
+      Project.NameKey projectName, String branchName, RenameEmailInput input)
+      throws RestApiException {
+    return projectCodeOwnersApiFactory
+        .project(projectName)
+        .branch(branchName)
+        .renameEmailInCodeOwnerConfigFiles(input);
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java
index aa43cc1..d70be9f 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java
@@ -52,7 +52,8 @@
   private static final ImmutableList<RestCall> BRANCH_ENDPOINTS =
       ImmutableList.of(
           RestCall.get("/projects/%s/branches/%s/code-owners~code_owners.config_files"),
-          RestCall.get("/projects/%s/branches/%s/code-owners~code_owners.branch_config"));
+          RestCall.get("/projects/%s/branches/%s/code-owners~code_owners.branch_config"),
+          RestCall.post("/projects/%s/branches/%s/code-owners~code_owners.rename"));
 
   private static final ImmutableList<RestCall> BRANCH_CODE_OWNER_CONFIGS_ENDPOINTS =
       ImmutableList.of(RestCall.get("/projects/%s/branches/%s/code-owners~code_owners.config/%s"));
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/RenameEmailRestIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/RenameEmailRestIT.java
new file mode 100644
index 0000000..5978272
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/RenameEmailRestIT.java
@@ -0,0 +1,93 @@
+// 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.acceptance.restapi;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.api.RenameEmailInput;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
+import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
+import com.google.gerrit.plugins.codeowners.config.BackendConfig;
+import com.google.inject.Inject;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Acceptance test for the {@link com.google.gerrit.plugins.codeowners.restapi.RenameEmail} REST
+ * endpoint. that require using via REST.
+ *
+ * <p>Acceptance test for the {@link com.google.gerrit.plugins.codeowners.restapi.RenameEmail} REST
+ * endpoint that can use the Java API are implemented in {@link
+ * com.google.gerrit.plugins.codeowners.acceptance.api.RenameEmailIT}.
+ */
+public class RenameEmailRestIT extends AbstractCodeOwnersIT {
+  @Inject private AccountOperations accountOperations;
+
+  private BackendConfig backendConfig;
+
+  @Before
+  public void setup() throws Exception {
+    backendConfig = plugin.getSysInjector().getInstance(BackendConfig.class);
+  }
+
+  @Test
+  public void cannotRenameEmailsAnonymously() throws Exception {
+    RestResponse r =
+        anonymousRestSession.post(
+            String.format(
+                "/projects/%s/branches/%s/code_owners.rename",
+                IdString.fromDecoded(project.get()), "master"));
+    r.assertForbidden();
+    assertThat(r.getEntityContent()).contains("Authentication required");
+  }
+
+  @Test
+  public void renameEmailNotSupported() throws Exception {
+    // renaming email is only unsupported for the proto backend
+    assume().that(backendConfig.getDefaultBackend()).isInstanceOf(ProtoBackend.class);
+
+    String secondaryEmail = "user-foo@example.com";
+    accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    RenameEmailInput input = new RenameEmailInput();
+    input.oldEmail = user.email();
+    input.newEmail = secondaryEmail;
+    RestResponse r =
+        adminRestSession.post(
+            String.format(
+                "/projects/%s/branches/%s/code_owners.rename",
+                IdString.fromDecoded(project.get()), "master"),
+            input);
+    r.assertMethodNotAllowed();
+    assertThat(r.getEntityContent())
+        .contains(
+            String.format(
+                "rename email not supported by %s backend",
+                CodeOwnerBackendId.getBackendId(backendConfig.getDefaultBackend().getClass())));
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java
index b92fc0c..69c9e7a 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java
@@ -16,10 +16,12 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerConfigSubject.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.joining;
 
+import com.google.common.base.Joiner;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.plugins.codeowners.backend.AbstractCodeOwnerConfigParserTest;
@@ -34,6 +36,7 @@
 import com.google.gerrit.plugins.codeowners.testing.CodeOwnerSetSubject;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.regex.Pattern;
 import org.junit.Test;
 
 /** Tests for {@link FindOwnersCodeOwnerConfigParser}. */
@@ -603,4 +606,132 @@
               .isEqualTo(CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY);
         });
   }
+
+  @Test
+  public void replaceEmail_contentCannotBeNull() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                FindOwnersCodeOwnerConfigParser.replaceEmail(
+                    /** content = */
+                    null, admin.email(), user.email()));
+    assertThat(npe).hasMessageThat().isEqualTo("codeOwnerConfigFileContent");
+  }
+
+  @Test
+  public void replaceEmail_oldEmailCannotBeNull() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                FindOwnersCodeOwnerConfigParser.replaceEmail(
+                    "content",
+                    /** oldEmail = */
+                    null,
+                    user.email()));
+    assertThat(npe).hasMessageThat().isEqualTo("oldEmail");
+  }
+
+  @Test
+  public void replaceEmail_newEmailCannotBeNull() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                FindOwnersCodeOwnerConfigParser.replaceEmail(
+                    "content",
+                    admin.email(),
+                    /** newEmail = */
+                    null));
+    assertThat(npe).hasMessageThat().isEqualTo("newEmail");
+  }
+
+  @Test
+  public void replaceEmail() throws Exception {
+    String oldEmail = "old@example.com";
+    String newEmail = "new@example.com";
+
+    // In the following test lines '${email} is used as placefolder for the email that we expect to
+    // be replaced.
+    String[] testLines = {
+      // line = email
+      "${email}",
+      // line = email with leading whitespace
+      " ${email}",
+      // line = email with trailing whitespace
+      "${email} ",
+      // line = email with leading and trailing whitespace
+      " ${email} ",
+      // line = email with comment
+      "${email}# comment",
+      // line = email with trailing whitespace and comment
+      "${email} # comment",
+      // line = email with leading whitespace and comment
+      " ${email}# comment",
+      // line = email with leading and trailing whitespace and comment
+      " ${email} # comment",
+      // line = email that ends with oldEmail
+      "foo" + oldEmail,
+      // line = email that starts with oldEmail
+      oldEmail + "bar",
+      // line = email that contains oldEmail
+      "foo" + oldEmail + "bar",
+      // line = email that contains oldEmail with leading and trailing whitespace
+      " foo" + oldEmail + "bar ",
+      // line = email that contains oldEmail with leading and trailing whitespace and comment
+      " foo" + oldEmail + "bar # comment",
+      // email in comment
+      "foo@example.com # ${email}",
+      // email in comment that contains oldEmail
+      "foo@example.com # foo" + oldEmail + "bar",
+      // per-file line with email
+      "per-file *.md=${email}",
+      // per-file line with email and whitespace
+      "per-file *.md = ${email} ",
+      // per-file line with multiple emails and old email at first position
+      "per-file *.md=${email},foo@example.com,bar@example.com",
+      // per-file line with multiple emails and old email at middle position
+      "per-file *.md=foo@example.com,${email},bar@example.com",
+      // per-file line with multiple emails and old email at last position
+      "per-file *.md=foo@example.com,bar@example.com,${email}",
+      // per-file line with multiple emails and old email at last position and comment
+      "per-file *.md=foo@example.com,bar@example.com,${email}# comment",
+      // per-file line with multiple emails and old email at last position and comment with
+      // whitespace
+      "per-file *.md = foo@example.com, bar@example.com , ${email} # comment",
+      // per-file line with multiple emails and old email appearing multiple times
+      "per-file *.md=${email},${email}",
+      "per-file *.md=${email},${email},${email}",
+      "per-file *.md=${email},foo@example.com,${email},bar@example.com,${email}",
+      // per-file line with multiple emails and old email appearing multiple times and comment
+      "per-file *.md=${email},foo@example.com,${email},bar@example.com,${email}# comment",
+      // per-file line with multiple emails and old email appearing multiple times and comment and
+      // whitespace
+      "per-file *.md= ${email} , foo@example.com , ${email} , bar@example.com , ${email} # comment",
+      // per-file line with email that contains old email
+      "per-file *.md=for" + oldEmail + "bar",
+      // per-file line with multiple emails and one email that contains the old email
+      "per-file *.md=for" + oldEmail + "bar,${email}",
+    };
+
+    for (String testLine : testLines) {
+      String content = testLine.replaceAll(Pattern.quote("${email}"), oldEmail);
+      String expectedContent = testLine.replaceAll(Pattern.quote("${email}"), newEmail);
+      assertWithMessage(testLine)
+          .that(FindOwnersCodeOwnerConfigParser.replaceEmail(content, oldEmail, newEmail))
+          .isEqualTo(expectedContent);
+    }
+
+    // join all test lines and replace email in all of them at once
+    String testContent = Joiner.on("\n").join(testLines);
+    String content = testContent.replaceAll(Pattern.quote("${email}"), oldEmail);
+    String expectedContent = testContent.replaceAll(Pattern.quote("${email}"), newEmail);
+    assertThat(FindOwnersCodeOwnerConfigParser.replaceEmail(content, oldEmail, newEmail))
+        .isEqualTo(expectedContent);
+
+    // test that trailing new line is preserved
+    assertThat(FindOwnersCodeOwnerConfigParser.replaceEmail(content + "\n", oldEmail, newEmail))
+        .isEqualTo(expectedContent + "\n");
+  }
 }
diff --git a/resources/Documentation/rest-api.md b/resources/Documentation/rest-api.md
index 1978bd0..3fd9f84 100644
--- a/resources/Documentation/rest-api.md
+++ b/resources/Documentation/rest-api.md
@@ -214,6 +214,70 @@
   ]
 ```
 
+### <a id="rename-email-in-code-owner-config-files">Rename Email In Code Owner Config Files
+_'POST /projects/[\{project-name\}](../../../Documentation/rest-api-projects.html#project-name)/branches/[\{branch-id\}](../../../Documentation/rest-api-projects.html#branch-id)/code_owners.rename/'_
+
+Renames an email in all code owner config files in the branch.
+
+The old and new email must be specified in the request body as
+[RenameEmailInput](#rename-email-input).
+
+The old and new email must both belong to the same Gerrit account.
+
+All updates are done atomically within one commit. The calling user will be the
+author of this commit.
+
+Requires that the calling user is a project owner
+([Owner](../../../Documentation/access-control.html#category_owner) permission
+on ‘refs/*’) or has
+[direct push](../../../Documentation/access-control.html#category_push)
+permissions for the branch.
+
+#### Request
+
+```
+  POST /projects/foo%2Fbar/branches/master/code_owners.rename HTTP/1.0
+```
+
+#### Response
+
+As response a [RenameEmailResultInfo](#rename-email-result-info) entity is
+returned.
+
+```
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "commit": {
+      "commit": "",
+      "parents": [
+        {
+          "commit": "1efe2c9d8f352483781e772f35dc586a69ff5646",
+          "subject": "Fix Foo Bar"
+        }
+      ],
+      "author": {
+        "name": "John Doe",
+        "email": "john.doe@example.com",
+        "date": "2020-03-30 18:08:08.000000000",
+        "tz": -420
+      },
+      "committer": {
+        "name": "Gerrit Code Review",
+        "email": "no-reply@gerritcodereview.com",
+        "date": "2020-03-30 18:08:08.000000000",
+        "tz": -420
+      },
+      "subject": "Rename email in code owner config files",
+      "message": "Rename email in code owner config files"
+    }
+  }
+```
+
+
 ### <a id="get-code-owner-config">[EXPERIMENTAL] Get Code Owner Config
 _'GET /projects/[\{project-name\}](../../../Documentation/rest-api-projects.html#project-name)/branches/[\{branch-id\}](../../../Documentation/rest-api-projects.html#branch-id)/code_owners.config/[\{path\}](#path)'_
 
@@ -633,6 +697,27 @@
 
 ---
 
+### <a id="rename-email-input"> RenameEmailInput
+The `RenameEmailInput` entity specifies how an email should be renamed.
+
+| Field Name  |          | Description |
+| ----------- | -------- | ----------- |
+| `message`   | optional | Commit message that should be used for the commit that renames the email in the code owner config files. If not set the following default commit message is used: "Rename email in code owner config files"
+| `old_email` || The old email that should be replaced with the new email.
+| `new_email` || The new email that should be used to replace the old email.
+
+---
+
+### <a id="rename-email-result-info"> RenameEmailResultInfo
+The `RenameEmailResultInfo` entity describes the result of the rename email REST
+endpoint.
+
+| Field Name  |          | Description |
+| ----------- | -------- | ----------- |
+| `commit`    | optional | The commit that did the email rename. Not set, if no update was necessary.
+
+---
+
 ### <a id="required-approval-info"> RequiredApprovalInfo
 The `RequiredApprovalInfo` entity describes an approval that is required for an
 action.