Merge branch 'stable-3.3'

* stable-3.3:
  Split up the api and backend tests

Change-Id: I1b49b2f9972bee39f0af0044d2bf483a2cffe21d
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
index adccb0f..a93c20d 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.UserInitiated;
+import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.inject.Provides;
 
 /** Guice module to bind code owner backends. */
@@ -45,6 +46,7 @@
     install(new CodeOwnerSubmitRule.Module());
 
     DynamicSet.bind(binder(), ExceptionHook.class).to(CodeOwnersExceptionHook.class);
+    DynamicSet.bind(binder(), OnPostReview.class).to(CodeOwnersOnPostReview.class);
   }
 
   @Provides
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnPostReview.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnPostReview.java
new file mode 100644
index 0000000..61574f4
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnPostReview.java
@@ -0,0 +1,255 @@
+// 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.backend;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.Comparator.comparing;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.plugins.codeowners.JgitPath;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.config.RequiredApproval;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.restapi.change.OnPostReview;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * Callback that is invoked on post review.
+ *
+ * <p>If a code owner approval was added, removed or changed, include in the change message that is
+ * being posted on vote, which of the files:
+ *
+ * <ul>
+ *   <li>are approved now
+ *   <li>are no longer approved
+ *   <li>are still approved
+ * </ul>
+ */
+@Singleton
+class CodeOwnersOnPostReview implements OnPostReview {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+  private final CodeOwnerApprovalCheck codeOwnerApprovalCheck;
+
+  @Inject
+  CodeOwnersOnPostReview(
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+      CodeOwnerApprovalCheck codeOwnerApprovalCheck) {
+    this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
+    this.codeOwnerApprovalCheck = codeOwnerApprovalCheck;
+  }
+
+  @Override
+  public Optional<String> getChangeMessageAddOn(
+      IdentifiedUser user,
+      ChangeNotes changeNotes,
+      PatchSet patchSet,
+      Map<String, Short> oldApprovals,
+      Map<String, Short> approvals) {
+    // code owner approvals are only computed for the current patch set
+    if (!changeNotes.getChange().currentPatchSetId().equals(patchSet.id())) {
+      return Optional.empty();
+    }
+
+    RequiredApproval requiredApproval =
+        codeOwnersPluginConfiguration.getRequiredApproval(changeNotes.getProjectName());
+
+    if (!oldApprovals.containsKey(requiredApproval.labelType().getName())) {
+      // if oldApprovals doesn't contain the label, the label was not changed
+      return Optional.empty();
+    }
+
+    return buildMessageForCodeOwnerApproval(
+        user, changeNotes, patchSet, oldApprovals, approvals, requiredApproval);
+  }
+
+  private Optional<String> buildMessageForCodeOwnerApproval(
+      IdentifiedUser user,
+      ChangeNotes changeNotes,
+      PatchSet patchSet,
+      Map<String, Short> oldApprovals,
+      Map<String, Short> approvals,
+      RequiredApproval requiredApproval) {
+    int maxPathsInChangeMessage =
+        codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(changeNotes.getProjectName());
+    if (maxPathsInChangeMessage <= 0) {
+      return Optional.empty();
+    }
+
+    LabelVote newVote = getNewVote(requiredApproval, approvals);
+
+    ImmutableList<Path> ownedPaths = getOwnedPaths(changeNotes, user.getAccountId());
+    if (ownedPaths.isEmpty()) {
+      // the user doesn't own any of the modified paths
+      return Optional.empty();
+    }
+
+    boolean hasImplicitApprovalByUser =
+        codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(changeNotes.getProjectName())
+            && patchSet.uploader().equals(user.getAccountId());
+
+    boolean noLongerExplicitlyApproved = false;
+    StringBuilder message = new StringBuilder();
+    if (isCodeOwnerApprovalNewlyApplied(requiredApproval, oldApprovals, newVote)) {
+      if (hasImplicitApprovalByUser) {
+        message.append(
+            String.format(
+                "By voting %s the following files are now explicitly code-owner approved by %s:\n",
+                newVote, user.getName()));
+      } else {
+        message.append(
+            String.format(
+                "By voting %s the following files are now code-owner approved by %s:\n",
+                newVote, user.getName()));
+      }
+    } else if (isCodeOwnerApprovalRemoved(requiredApproval, oldApprovals, newVote)) {
+      if (newVote.value() == 0) {
+        if (hasImplicitApprovalByUser) {
+          noLongerExplicitlyApproved = true;
+          message.append(
+              String.format(
+                  "By removing the %s vote the following files are no longer explicitly code-owner"
+                      + " approved by %s:\n",
+                  newVote.label(), user.getName()));
+        } else {
+          message.append(
+              String.format(
+                  "By removing the %s vote the following files are no longer code-owner approved"
+                      + " by %s:\n",
+                  newVote.label(), user.getName()));
+        }
+      } else {
+        if (hasImplicitApprovalByUser) {
+          noLongerExplicitlyApproved = true;
+          message.append(
+              String.format(
+                  "By voting %s the following files are no longer explicitly code-owner approved by"
+                      + " %s:\n",
+                  newVote, user.getName()));
+        } else {
+          message.append(
+              String.format(
+                  "By voting %s the following files are no longer code-owner approved by %s:\n",
+                  newVote, user.getName()));
+        }
+      }
+    } else if (isCodeOwnerApprovalUpOrDowngraded(requiredApproval, oldApprovals, newVote)) {
+      if (hasImplicitApprovalByUser) {
+        message.append(
+            String.format(
+                "By voting %s the following files are still explicitly code-owner approved by"
+                    + " %s:\n",
+                newVote, user.getName()));
+      } else {
+        message.append(
+            String.format(
+                "By voting %s the following files are still code-owner approved by %s:\n",
+                newVote, user.getName()));
+      }
+    } else {
+      return Optional.empty();
+    }
+
+    if (ownedPaths.size() <= maxPathsInChangeMessage) {
+      appendPaths(message, ownedPaths.stream());
+    } else {
+      // -1 so that we never show "(1 more files)"
+      int limit = maxPathsInChangeMessage - 1;
+      appendPaths(message, ownedPaths.stream().limit(limit));
+      message.append(String.format("(%s more files)\n", ownedPaths.size() - limit));
+    }
+
+    if (hasImplicitApprovalByUser && noLongerExplicitlyApproved) {
+      message.append(
+          String.format(
+              "\nThe listed files are still implicitly approved by %s.\n", user.getName()));
+    }
+
+    return Optional.of(message.toString());
+  }
+
+  private void appendPaths(StringBuilder message, Stream<Path> pathsToAppend) {
+    pathsToAppend.forEach(path -> message.append(String.format("* %s\n", JgitPath.of(path).get())));
+  }
+
+  private boolean isCodeOwnerApprovalNewlyApplied(
+      RequiredApproval requiredApproval, Map<String, Short> oldApprovals, LabelVote newVote) {
+    String labelName = requiredApproval.labelType().getName();
+    return oldApprovals.get(labelName) < requiredApproval.value()
+        && newVote.value() >= requiredApproval.value();
+  }
+
+  private boolean isCodeOwnerApprovalRemoved(
+      RequiredApproval requiredApproval, Map<String, Short> oldApprovals, LabelVote newVote) {
+    String labelName = requiredApproval.labelType().getName();
+    return oldApprovals.get(labelName) >= requiredApproval.value()
+        && newVote.value() < requiredApproval.value();
+  }
+
+  private boolean isCodeOwnerApprovalUpOrDowngraded(
+      RequiredApproval requiredApproval, Map<String, Short> oldApprovals, LabelVote newVote) {
+    String labelName = requiredApproval.labelType().getName();
+    return oldApprovals.get(labelName) >= requiredApproval.value()
+        && newVote.value() >= requiredApproval.value();
+  }
+
+  private LabelVote getNewVote(RequiredApproval requiredApproval, Map<String, Short> approvals) {
+    String labelName = requiredApproval.labelType().getName();
+    checkState(
+        approvals.containsKey(labelName),
+        "expected that approval on label %s exists (approvals = %s)",
+        labelName,
+        approvals);
+    return LabelVote.create(labelName, approvals.get(labelName));
+  }
+
+  private ImmutableList<Path> getOwnedPaths(ChangeNotes changeNotes, Account.Id accountId) {
+    try {
+      return codeOwnerApprovalCheck
+          .getFileStatusesForAccount(changeNotes, accountId)
+          .flatMap(
+              fileCodeOwnerStatus ->
+                  Stream.of(
+                          fileCodeOwnerStatus.newPathStatus(), fileCodeOwnerStatus.oldPathStatus())
+                      .filter(Optional::isPresent)
+                      .map(Optional::get))
+          .filter(PathCodeOwnerStatus -> PathCodeOwnerStatus.status() == CodeOwnerStatus.APPROVED)
+          .map(PathCodeOwnerStatus::path)
+          .sorted(comparing(Path::toString))
+          .collect(toImmutableList());
+    } catch (IOException | ResourceConflictException | PatchListNotAvailableException e) {
+      logger.atSevere().withCause(e).log(
+          "Failed to compute owned paths of change %s for account %s",
+          changeNotes.getChangeId(), accountId.get());
+      return ImmutableList.of();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigValidator.java b/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigValidator.java
index 8f20fc1..6a76360 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigValidator.java
+++ b/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigValidator.java
@@ -104,7 +104,7 @@
           String.format(
               "failed to validate file %s for revision %s in ref %s of project %s",
               fileName, receiveEvent.commit.getName(), RefNames.REFS_CONFIG, project);
-      logger.atSevere().log(errorMessage);
+      logger.atSevere().withCause(e).log(errorMessage);
       throw new CommitValidationException(errorMessage, e);
     }
   }
diff --git a/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java b/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java
index 1a7985d..f55642b 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java
+++ b/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java
@@ -166,6 +166,17 @@
   }
 
   /**
+   * Gets the max paths in change messages for the given project.
+   *
+   * @param project the project for which the fallback code owners should be retrieved
+   * @return the fallback code owners for the given project
+   */
+  public int getMaxPathsInChangeMessages(Project.NameKey project) {
+    requireNonNull(project, "project");
+    return generalConfig.getMaxPathsInChangeMessages(project, getPluginConfig(project));
+  }
+
+  /**
    * Checks whether an implicit code owner approval from the last uploader is assumed.
    *
    * @param project the project for it should be checked whether implict approvals are enabled
diff --git a/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java b/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java
index 85fd19a..cafe8e7 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java
@@ -69,6 +69,9 @@
   @VisibleForTesting
   public static final String KEY_ENABLE_VALIDATION_ON_SUBMIT = "enableValidationOnSubmit";
 
+  @VisibleForTesting
+  public static final String KEY_MAX_PATHS_IN_CHANGE_MESSAGES = "maxPathsInChangeMessages";
+
   @VisibleForTesting public static final String KEY_MERGE_COMMIT_STRATEGY = "mergeCommitStrategy";
   @VisibleForTesting public static final String KEY_GLOBAL_CODE_OWNER = "globalCodeOwner";
 
@@ -77,6 +80,8 @@
 
   @VisibleForTesting public static final String KEY_OVERRIDE_INFO_URL = "overrideInfoUrl";
 
+  @VisibleForTesting static final int DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES = 100;
+
   private final String pluginName;
   private final PluginConfig pluginConfigFromGerritConfig;
 
@@ -141,6 +146,29 @@
               ValidationMessage.Type.ERROR));
     }
 
+    try {
+      projectLevelConfig
+          .getConfig()
+          .getInt(
+              SECTION_CODE_OWNERS,
+              null,
+              KEY_MAX_PATHS_IN_CHANGE_MESSAGES,
+              DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+    } catch (IllegalArgumentException e) {
+      validationMessages.add(
+          new CommitValidationMessage(
+              String.format(
+                  "The value for max paths in change messages '%s' that is configured in %s"
+                      + " (parameter %s.%s) is invalid.",
+                  projectLevelConfig
+                      .getConfig()
+                      .getString(SECTION_CODE_OWNERS, null, KEY_MAX_PATHS_IN_CHANGE_MESSAGES),
+                  fileName,
+                  SECTION_CODE_OWNERS,
+                  KEY_MAX_PATHS_IN_CHANGE_MESSAGES),
+              ValidationMessage.Type.ERROR));
+    }
+
     return ImmutableList.copyOf(validationMessages);
   }
 
@@ -237,6 +265,51 @@
   }
 
   /**
+   * Gets the maximum number of paths that should be incuded in change messages.
+   *
+   * @param project the project for which the maximum number of paths in change messages should be
+   *     read
+   * @param pluginConfig the plugin config from which the maximum number of paths in change messages
+   *     should be read
+   * @return the maximum number of paths in change messages
+   */
+  int getMaxPathsInChangeMessages(Project.NameKey project, Config pluginConfig) {
+    requireNonNull(project, "project");
+    requireNonNull(pluginConfig, "pluginConfig");
+
+    String maxPathInChangeMessagesString =
+        pluginConfig.getString(SECTION_CODE_OWNERS, null, KEY_MAX_PATHS_IN_CHANGE_MESSAGES);
+    if (maxPathInChangeMessagesString != null) {
+      try {
+        return pluginConfig.getInt(
+            SECTION_CODE_OWNERS,
+            null,
+            KEY_MAX_PATHS_IN_CHANGE_MESSAGES,
+            DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+      } catch (IllegalArgumentException e) {
+        logger.atWarning().log(
+            "Ignoring invalid value %s for max paths in change messages in '%s.config' of"
+                + " project %s. Falling back to global config.",
+            maxPathInChangeMessagesString, pluginName, project.get());
+      }
+    }
+
+    try {
+      return pluginConfigFromGerritConfig.getInt(
+          KEY_MAX_PATHS_IN_CHANGE_MESSAGES, DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+    } catch (IllegalArgumentException e) {
+      logger.atWarning().log(
+          "Ignoring invalid value %s for max paths in change messages in gerrit.config (parameter"
+              + " plugin.%s.%s). Falling back to default value %s.",
+          pluginConfigFromGerritConfig.getString(KEY_MAX_PATHS_IN_CHANGE_MESSAGES),
+          pluginName,
+          KEY_MAX_PATHS_IN_CHANGE_MESSAGES,
+          DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+      return DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES;
+    }
+  }
+
+  /**
    * Gets the enable validation on commit received configuration from the given plugin config with
    * fallback to {@code gerrit.config} and default to {@code true}.
    *
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnPostReviewIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnPostReviewIT.java
new file mode 100644
index 0000000..e17570a
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnPostReviewIT.java
@@ -0,0 +1,569 @@
+// 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 com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import java.util.Collection;
+import org.junit.Test;
+
+/**
+ * Acceptance test for {@code com.google.gerrit.plugins.codeowners.backend.CodeOwnersOnPostReview}.
+ */
+public class CodeOwnersOnPostReviewIT extends AbstractCodeOwnersIT {
+  @Test
+  public void changeMessageListsNewlyApprovedPaths() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review+1\n\n"
+                    + "By voting Code-Review+1 the following files are now code-owner approved by"
+                    + " %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  public void changeMessageListsPathsThatAreStillApproved() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    // Upgrade the approval from Code-Review+1 to Code-Review+2
+    approve(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review+2\n\n"
+                    + "By voting Code-Review+2 the following files are still code-owner approved by"
+                    + " %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  public void changeMessageListsPathsThatAreNoLongerApproved_voteRemoved() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    // Remove the approval.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Code-Review", 0));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: -Code-Review\n\n"
+                    + "By removing the Code-Review vote the following files are no longer"
+                    + " code-owner approved by %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  public void changeMessageListsPathsThatAreNoLongerApproved_voteChangedToNegativeValue()
+      throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    // Vote with a negative value.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Code-Review", -1));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review-1\n\n"
+                    + "By voting Code-Review-1 the following files are no longer code-owner"
+                    + " approved by %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  public void changeMessageListsOnlyApprovedPaths() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path1 = "foo/bar.baz";
+    String path2 = "foo/baz.bar";
+    String changeId =
+        createChange(
+                "Test Change",
+                ImmutableMap.of(
+                    path1,
+                    "file content",
+                    path2,
+                    "file content",
+                    "bar/foo.baz",
+                    "file content",
+                    "bar/baz.foo",
+                    "file content"))
+            .getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review+1\n\n"
+                    + "By voting Code-Review+1 the following files are now code-owner approved by"
+                    + " %s:\n"
+                    + "* %s\n"
+                    + "* %s\n",
+                admin.fullName(), path1, path2));
+  }
+
+  @Test
+  public void changeMessageListsOnlyApprovedPaths_fileRenamed() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    // createChangeWithFileRename creates a change with 2 patch sets
+    String oldPath = "foo/bar.baz";
+    String newPath = "bar/baz.bar";
+    String changeId = createChangeWithFileRename(oldPath, newPath);
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 2: Code-Review+1\n\n"
+                    + "By voting Code-Review+1 the following files are now code-owner approved by"
+                    + " %s:\n"
+                    + "* %s\n",
+                admin.fullName(), oldPath));
+  }
+
+  @Test
+  public void changeMessageNotExtendedIfUserOwnsNoneOfTheFiles() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String changeId =
+        createChange(
+                "Test Change",
+                ImmutableMap.of("bar/foo.baz", "file content", "bar/baz.foo", "file content"))
+            .getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Code-Review+1");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Owners-Approval+1")
+  public void changeMessageNotExtendedForNonCodeOwnerApproval() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Owner Approval", " 0", "No Owner Approval");
+    gApi.projects().name(project.get()).label("Owners-Approval").create(input).get();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String changeId =
+        createChange(
+                "Test Change",
+                ImmutableMap.of("bar/foo.baz", "file content", "bar/baz.foo", "file content"))
+            .getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Code-Review+1");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void changeMessageListsNewlyApprovedPathsIfTheyWereAlreadyImplicitlyApproved()
+      throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review+1\n\n"
+                    + "By voting Code-Review+1 the following files are now explicitly code-owner"
+                    + " approved by %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void changeMessageListsPathsThatAreNoLongerExplicitlyApproved_voteRemoved()
+      throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    // Remove the approval.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Code-Review", 0));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: -Code-Review\n\n"
+                    + "By removing the Code-Review vote the following files are no longer"
+                    + " explicitly code-owner approved by %s:\n"
+                    + "* %s\n"
+                    + "\n"
+                    + "The listed files are still implicitly approved by %s.\n",
+                admin.fullName(), path, admin.fullName()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void changeMessageListsPathsThatAreNoLongerExplicitlyApproved_voteChangedToNegativeValue()
+      throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    // Vote with a negative value.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Code-Review", -1));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review-1\n\n"
+                    + "By voting Code-Review-1 the following files are no longer explicitly"
+                    + " code-owner approved by %s:\n"
+                    + "* %s\n"
+                    + "\n"
+                    + "The listed files are still implicitly approved by %s.\n",
+                admin.fullName(), path, admin.fullName()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void changeMessageListsNewlyApprovedPaths_noImplicitApprovalButImplicitApprovalsEnabled()
+      throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange(user, "Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review+1\n\n"
+                    + "By voting Code-Review+1 the following files are now code-owner approved by"
+                    + " %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void
+      changeMessageListsPathsThatAreNoLongerApproved_voteRemoved_noImplicitApprovalButImplicitApprovalsEnabled()
+          throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange(user, "Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    // Remove the approval.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Code-Review", 0));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: -Code-Review\n\n"
+                    + "By removing the Code-Review vote the following files are no longer"
+                    + " code-owner approved by %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void
+      changeMessageListsPathsThatAreNoLongerApproved_voteChangedToNegativeValue_noImplicitApprovalButImplicitApprovalsEnabled()
+          throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange(user, "Test Change", path, "file content").getChangeId();
+
+    recommend(changeId);
+
+    // Vote with a negative value.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Code-Review", -1));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review-1\n\n"
+                    + "By voting Code-Review-1 the following files are no longer code-owner"
+                    + " approved by %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "4")
+  public void pathsInChangeMessageAreLimited_limitNotReached() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path1 = "foo/bar.baz";
+    String path2 = "foo/baz.bar";
+    String path3 = "bar/foo.baz";
+    String path4 = "bar/baz.foo";
+    String changeId =
+        createChange(
+                "Test Change",
+                ImmutableMap.of(
+                    path1,
+                    "file content",
+                    path2,
+                    "file content",
+                    path3,
+                    "file content",
+                    path4,
+                    "file content"))
+            .getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review+1\n\n"
+                    + "By voting Code-Review+1 the following files are now code-owner approved by"
+                    + " %s:\n"
+                    + "* %s\n"
+                    + "* %s\n"
+                    + "* %s\n"
+                    + "* %s\n",
+                admin.fullName(), path4, path3, path1, path2));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "3")
+  public void pathsInChangeMessageAreLimited_limitReached() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String path1 = "foo/bar.baz";
+    String path2 = "foo/baz.bar";
+    String path3 = "bar/foo.baz";
+    String path4 = "bar/baz.foo";
+    String changeId =
+        createChange(
+                "Test Change",
+                ImmutableMap.of(
+                    path1,
+                    "file content",
+                    path2,
+                    "file content",
+                    path3,
+                    "file content",
+                    path4,
+                    "file content"))
+            .getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Code-Review+1\n\n"
+                    + "By voting Code-Review+1 the following files are now code-owner approved by"
+                    + " %s:\n"
+                    + "* %s\n"
+                    + "* %s\n"
+                    + "(2 more files)\n",
+                admin.fullName(), path4, path3));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "0")
+  public void pathsInChangeMessagesDisabled() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String changeId =
+        createChange(
+                "Test Change",
+                ImmutableMap.of(
+                    "foo/bar.baz",
+                    "file content",
+                    "foo/baz.bar",
+                    "file content",
+                    "bar/foo.baz",
+                    "file content",
+                    "bar/baz.foo",
+                    "file content"))
+            .getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Code-Review+1");
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
index 1ea4183..e22062e 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
@@ -494,6 +494,44 @@
                 + " (parameter codeOwners.fallbackCodeOwners) is invalid.");
   }
 
+  @Test
+  public void configureMaxPathsInChangeMessages() throws Exception {
+    fetchRefsMetaConfig();
+
+    Config cfg = new Config();
+    cfg.setInt(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        GeneralConfig.KEY_MAX_PATHS_IN_CHANGE_MESSAGES,
+        50);
+    setCodeOwnersConfig(cfg);
+
+    PushResult r = pushRefsMetaConfig();
+    assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
+    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project)).isEqualTo(50);
+  }
+
+  @Test
+  public void cannotSetInvalidMaxPathsInChangeMessages() throws Exception {
+    fetchRefsMetaConfig();
+
+    Config cfg = new Config();
+    cfg.setString(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        GeneralConfig.KEY_MAX_PATHS_IN_CHANGE_MESSAGES,
+        "INVALID");
+    setCodeOwnersConfig(cfg);
+
+    PushResult r = pushRefsMetaConfig();
+    assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus())
+        .isEqualTo(Status.REJECTED_OTHER_REASON);
+    assertThat(r.getMessages())
+        .contains(
+            "The value for max paths in change messages 'INVALID' that is configured in"
+                + " code-owners.config (parameter codeOwners.maxPathsInChangeMessages) is invalid.");
+  }
+
   private void fetchRefsMetaConfig() throws Exception {
     fetch(testRepo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
     testRepo.reset(RefNames.REFS_CONFIG);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java b/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java
index 641bf10..f03427d 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java
@@ -922,6 +922,50 @@
     assertThat(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).isFalse();
   }
 
+  @Test
+  public void cannotGetMaxPathsInChangeMessagesForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(/* project= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void getMaxPathsInChangeMessagesIfNoneIsConfigured() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project))
+        .isEqualTo(GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void getMaxPathsInChangeMessagesIfNoneIsConfiguredOnProjectLevel() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project)).isEqualTo(50);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void maxPathInChangeMessagesOnProjectLevelOverridesGlobalMaxPathInChangeMessages()
+      throws Exception {
+    configureFallbackCodeOwners(project, FallbackCodeOwners.NONE);
+    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void maxPathInChangeMessagesIsInheritedFromParentProject() throws Exception {
+    configureMaxPathsInChangeMessages(allProjects, 20);
+    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project)).isEqualTo(20);
+  }
+
+  @Test
+  public void inheritedMaxPathInChangeMessagesCanBeOverridden() throws Exception {
+    configureMaxPathsInChangeMessages(allProjects, 50);
+    configureMaxPathsInChangeMessages(project, 20);
+    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project)).isEqualTo(20);
+  }
+
   private void configureDisabled(Project.NameKey project, String disabled) throws Exception {
     setCodeOwnersConfig(project, /* subsection= */ null, StatusConfig.KEY_DISABLED, disabled);
   }
@@ -987,6 +1031,15 @@
         fallbackCodeOwners.name());
   }
 
+  private void configureMaxPathsInChangeMessages(
+      Project.NameKey project, int maxPathsInChangeMessages) throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_MAX_PATHS_IN_CHANGE_MESSAGES,
+        Integer.toString(maxPathsInChangeMessages));
+  }
+
   private AutoCloseable registerTestBackend() {
     RegistrationHandle registrationHandle =
         ((PrivateInternals_DynamicMapImpl<CodeOwnerBackend>) codeOwnerBackends)
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java
index 6bc8ac5..78adae8 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java
@@ -16,12 +16,14 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
+import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_ENABLE_IMPLICIT_APPROVALS;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_FALLBACK_CODE_OWNERS;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_FILE_EXTENSION;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_GLOBAL_CODE_OWNER;
+import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_MAX_PATHS_IN_CHANGE_MESSAGES;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_MERGE_COMMIT_STRATEGY;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_OVERRIDE_INFO_URL;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_READ_ONLY;
@@ -500,4 +502,61 @@
     assertThat(generalConfig.getFallbackCodeOwners(project, new Config()))
         .isEqualTo(FallbackCodeOwners.NONE);
   }
+
+  @Test
+  public void cannotGetMaxPathsInChangeMessagesForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getMaxPathsInChangeMessages(/* project= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetMaxPathsInChangeMessagesForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getMaxPathsInChangeMessages(project, /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noMaxPathsInChangeMessagesConfigured() throws Exception {
+    assertThat(generalConfig.getMaxPathsInChangeMessages(project, new Config()))
+        .isEqualTo(DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void maxPathsInChangeMessagesIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+      throws Exception {
+    assertThat(generalConfig.getMaxPathsInChangeMessages(project, new Config())).isEqualTo(50);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void
+      maxPathsInChangeMessagesInPluginConfigOverridesMaxPathsInChangeMessagesInGerritConfig()
+          throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_MAX_PATHS_IN_CHANGE_MESSAGES, "10");
+    assertThat(generalConfig.getMaxPathsInChangeMessages(project, cfg)).isEqualTo(10);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void globalMaxPathsInChangeMessagesUsedIfInvalidMaxPathsInChangeMessagesConfigured()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_MAX_PATHS_IN_CHANGE_MESSAGES, "INVALID");
+    assertThat(generalConfig.getMaxPathsInChangeMessages(project, cfg)).isEqualTo(50);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "INVALID")
+  public void defaultValueUsedIfInvalidMaxPathsInChangeMessagesConfigured() throws Exception {
+    assertThat(generalConfig.getMaxPathsInChangeMessages(project, new Config()))
+        .isEqualTo(DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+  }
 }
diff --git a/resources/Documentation/config.md b/resources/Documentation/config.md
index 28c65b9..d546374 100644
--- a/resources/Documentation/config.md
+++ b/resources/Documentation/config.md
@@ -283,6 +283,21 @@
         `@PLUGIN@.config`.\
         By default `NONE`.
 
+<a id="pluginCodeOwnersMaxPathsInChangeMessages">plugin.@PLUGIN@.maxPathsInChangeMessages</a>
+:       When a user votes on the [code owners
+        label](#pluginCodeOwnersRequiredApproval) the paths that are affected by
+        the vote are listed in the change message that is posted when the vote
+        is applied.\
+        This configuration parameter controls the maximum number of paths that
+        are included in change messages. This is to prevent that the change
+        messages become too big for large changes that touch many files.\
+        Setting the value to `0` disables including affected paths into change
+        messages.\
+        Can be overridden per project by setting
+        [codeOwners.maxPathsInChangeMessages](#codeOwnersMaxPathsInChangeMessages)
+        in `@PLUGIN@.config`.\
+        By default `100`.
+
 # <a id="projectConfiguration">Project configuration in @PLUGIN@.config</a>
 
 <a id="codeOwnersDisabled">codeOwners.disabled</a>
@@ -530,6 +545,24 @@
         [plugin.@PLUGIN@.fallbackCodeOwners](#pluginCodeOwnersFallbackCodeOwners)
         in `gerrit.config` is used.
 
+<a id="codeOwnersMaxPathsInChangeMessages">codeOwners.maxPathsInChangeMessages</a>
+:       When a user votes on the [code owners
+        label](#codeOwnersRequiredApproval) the paths that are affected by the
+        vote are listed in the change message that is posted when the vote is
+        applied.\
+        This configuration parameter controls the maximum number of paths that
+        are included in change messages. This is to prevent that the change
+        messages become too big for large changes that touch many files.\
+        Setting the value to `0` disables including affected paths into change
+        messages.\
+        Overrides the global setting
+        [plugin.@PLUGIN@.maxPathsInChangeMessages](#pluginCodeOwnersMaxPathsInChangeMessages)
+        in `gerrit.config`.\
+        If not set, the global setting
+        [plugin.@PLUGIN@.maxPathsInChangeMessages](#pluginCodeOwnersMaxPathsInChangeMessages)
+        in `gerrit.config` is used.\
+        By default `100`.
+
 ---
 
 Back to [@PLUGIN@ documentation index](index.html)
diff --git a/resources/Documentation/validation.md b/resources/Documentation/validation.md
index ef1b4ce..746221e 100644
--- a/resources/Documentation/validation.md
+++ b/resources/Documentation/validation.md
@@ -154,6 +154,8 @@
   configuration is valid
 * the [codeOwners.fallbackCodeOwners](config.html#codeOwnersFallbackCodeOwners)
   configuration is valid
+* the [codeOwners.maxPathsInChangeMessages](config.html#codeOwnersMaxPathsInChangeMessages)
+  configuration is valid
 
 ---