On vote include approved paths into the change message

When a code owner votes on the code owner label, include into the change
message which files/paths:

* are approved now
* are no longer approved
* are still approved

This makes makes the effect on voting on the code owner label more
transparent to users.

The paths in the change message are absolute file paths. They do not
have a leading '/' so that they match the paths in the file list on the
change screen which also do not have a leading '/'.

Example change message:

"By voting Code-Review+1 the following files are now code-owner approved
by John Doe:
* foo/bar.baz
* foo/baz.bar"
* foo/bar/baz.foo"

Please note that the change message will contain old and new paths, e.g.
if a file is renamed, both the old and new path will be included
(assuming that they are both owned by the user).

In a follow-up change we may also extend the change message if a code
owner override is applied.

Signed-off-by: Edwin Kempin <ekempin@google.com>
Change-Id: Idd11f2ea6021384a92f0a5588088e5cfda20a6c0
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..376c483
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnPostReview.java
@@ -0,0 +1,238 @@
+// 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) {
+    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();
+    }
+
+    ownedPaths.forEach(path -> message.append(String.format("* %s\n", JgitPath.of(path).get())));
+
+    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 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/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..f3507a9
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnPostReviewIT.java
@@ -0,0 +1,449 @@
+// 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));
+  }
+}