Append approval info to every comment-added stream event and hook

This change is driven by the RFC proposal[1] to show label
transitions more explicitly in Gerrit. Stream events only
provides label information on review events that contain
a vote transition.  When a user submits a review with only a
reply message the approvals information is not added to the
stream event and hooks. This change will make Gerrit append
approvals info to every comment-added event including the
ones that do not contain vote changes.

Since labels are shown for every event we will add an 'oldValue'
property to the approvalAttribute to distinguish when a vote
transition has occurred.  The 'oldValue' attribute contains
the previous vote score.  This attribute will only appear when
there is a vote transition.

The comment-added stream event will look like this:

  "approvals":[{"type":"Code-Review","description":"Code-Review",
  "value":"-1", "oldValue":"0"}, {"type":"Verified",
  "description":"Verified","value":"0"}],
  "comment":"Patch Set 1: Code-Review+1\n\nMy Message" ...

This change will also a '--$LabelName-oldValue' parameter to
corresponding comment-added change hook:

  hook[comment-added] output:
  --change I2cd8327360ff89338a4e7f0591bf0c037e9aa5db
  --is-draft false --change-url http://localhost:8080/201
  --change-owner John Smith (jsmith@gmail.com) --project okc
  --branch master --author John Smith (jsmith@gmail.com)
  --commit 55079187f7ce1c461de804c13f2dd71d64a85280
  --comment Patch Set 1: Code-Review-1 --Code-Review -1
  --Code-Review-oldValue 0 --Verified 0

[1] https://groups.google.com/d/msg/repo-discuss/soqmGjRpl-4/IIfAF4jbCQAJ

Bug: Issue 3220
Change-Id: Ibc1c64d70e790c7b507db79931f31fd77f25e276
diff --git a/Documentation/config-hooks.txt b/Documentation/config-hooks.txt
index 3068cc5..5b1f5e3 100644
--- a/Documentation/config-hooks.txt
+++ b/Documentation/config-hooks.txt
@@ -67,7 +67,7 @@
 This is called whenever a comment is added to a change.
 
 ====
-  comment-added --change <change id> --is-draft <boolean> --change-url <change url> --change-owner <change owner> --project <project name> --branch <branch> --topic <topic> --author <comment author> --commit <commit> --comment <comment> [--<approval category id> <score> --<approval category id> <score> ...]
+  comment-added --change <change id> --is-draft <boolean> --change-url <change url> --change-owner <change owner> --project <project name> --branch <branch> --topic <topic> --author <comment author> --commit <commit> --comment <comment> [--<approval category id> <score> --<approval category id> <score> --<approval category id>-oldValue <score> ...]
 ====
 
 === change-merged
diff --git a/Documentation/json.txt b/Documentation/json.txt
index 32fa472..db22212 100644
--- a/Documentation/json.txt
+++ b/Documentation/json.txt
@@ -140,6 +140,8 @@
 
 value:: Value assigned by the approval, usually a numerical score.
 
+oldValue:: The previous approval score, only present if the value changed as a result of this event.
+
 grantedOn:: Time in seconds since the UNIX epoch when this approval
 was added or last updated.
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
new file mode 100644
index 0000000..3c99dc3
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -0,0 +1,253 @@
+// Copyright (C) 2016 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.acceptance.server.event;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.project.Util.category;
+import static com.google.gerrit.server.project.Util.value;
+import static org.junit.Assert.fail;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.UserScopedEventListener;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.data.ApprovalAttribute;
+import com.google.gerrit.server.events.CommentAddedEvent;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.Util;
+import com.google.inject.Inject;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class CommentAddedEventIT extends AbstractDaemonTest {
+
+  @Inject
+  private IdentifiedUser.GenericFactory factory;
+
+  @Inject
+  private DynamicSet<UserScopedEventListener> source;
+
+  private final LabelType label = category("CustomLabel",
+      value(1, "Positive"),
+      value(0, "No score"),
+      value(-1, "Negative"));
+
+  private final LabelType pLabel = category("CustomLabel2",
+      value(1, "Positive"),
+      value(0, "No score"));
+
+  private RegistrationHandle eventListenerRegistration;
+  private CommentAddedEvent lastCommentAddedEvent;
+
+  @Before
+  public void setUp() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    AccountGroup.UUID anonymousUsers =
+        SystemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+    Util.allow(cfg, Permission.forLabel(label.getName()), -1, 1, anonymousUsers,
+        "refs/heads/*");
+    Util.allow(cfg, Permission.forLabel(pLabel.getName()), 0, 1, anonymousUsers,
+        "refs/heads/*");
+    saveProjectConfig(project, cfg);
+
+    eventListenerRegistration = source.add(new UserScopedEventListener() {
+      @Override
+      public void onEvent(Event event) {
+        if (event instanceof CommentAddedEvent) {
+          lastCommentAddedEvent = (CommentAddedEvent) event;
+        }
+      }
+
+      @Override
+      public CurrentUser getUser() {
+        return factory.create(user.id);
+      }
+    });
+  }
+
+  @After
+  public void cleanup() {
+    eventListenerRegistration.remove();
+  }
+
+  private void saveLabelConfig() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().put(label.getName(), label);
+    cfg.getLabelSections().put(pLabel.getName(), pLabel);
+    saveProjectConfig(project, cfg);
+  }
+
+  @Test
+  public void newChangeWithVote() throws Exception {
+    saveLabelConfig();
+
+    // push a new change with -1 vote
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = new ReviewInput().label(
+        label.getName(), (short)-1);
+    revision(r).review(reviewInput);
+    String newVote = lastCommentAddedEvent.approvals.get()[0].value;
+    String oldVote = lastCommentAddedEvent.approvals.get()[0].oldValue;
+    assertThat(oldVote).isEqualTo("0");
+    assertThat(newVote).isEqualTo("-1");
+    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+        String.format("Patch Set 1: %s-1", label.getName()));
+  }
+
+  @Test
+  public void newPatchSetWithVote() throws Exception {
+    saveLabelConfig();
+
+    // push a new change
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = new ReviewInput().message(label.getName());
+    revision(r).review(reviewInput);
+
+    // push a new revision with +1 vote
+    ChangeInfo c = get(r.getChangeId());
+    r = amendChange(c.changeId);
+    reviewInput = new ReviewInput().label(
+        label.getName(), (short)1);
+    revision(r).review(reviewInput);
+    String newVote = lastCommentAddedEvent.approvals.get()[0].value;
+    String oldVote = lastCommentAddedEvent.approvals.get()[0].oldValue;
+    assertThat(oldVote).isEqualTo("0");
+    assertThat(newVote).isEqualTo("1");
+    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+        String.format("Patch Set 2: %s+1", label.getName()));
+  }
+
+  @Test
+  public void reviewChange() throws Exception {
+    saveLabelConfig();
+
+    // push a change
+    PushOneCommit.Result r = createChange();
+
+    // review with message only, do not apply votes
+    ReviewInput reviewInput = new ReviewInput().message(label.getName());
+    revision(r).review(reviewInput);
+    // reply message only so votes are excluded from comment
+    assertThat(lastCommentAddedEvent.approvals.get()).isNull();
+    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+        String.format("Patch Set 1:\n\n%s", label.getName()));
+
+    // transition from un-voted to -1 vote
+    reviewInput = new ReviewInput().label(label.getName(), -1);
+    revision(r).review(reviewInput);
+    String newVote = lastCommentAddedEvent.approvals.get()[0].value;
+    String oldVote = lastCommentAddedEvent.approvals.get()[0].oldValue;
+    assertThat(oldVote).isEqualTo("0");
+    assertThat(newVote).isEqualTo("-1");
+    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+        String.format("Patch Set 1: %s-1", label.getName()));
+
+    // transition vote from -1 to 0
+    reviewInput = new ReviewInput().label(label.getName(), 0);
+    revision(r).review(reviewInput);
+    newVote = lastCommentAddedEvent.approvals.get()[0].value;
+    oldVote = lastCommentAddedEvent.approvals.get()[0].oldValue;
+    assertThat(oldVote).isEqualTo("-1");
+    assertThat(newVote).isEqualTo("0");
+    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+        String.format("Patch Set 1: -%s", label.getName()));
+
+    // transition vote from 0 to 1
+    reviewInput = new ReviewInput().label(label.getName(), 1);
+    revision(r).review(reviewInput);
+    newVote = lastCommentAddedEvent.approvals.get()[0].value;
+    oldVote = lastCommentAddedEvent.approvals.get()[0].oldValue;
+    assertThat(oldVote).isEqualTo("0");
+    assertThat(newVote).isEqualTo("1");
+    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+        String.format("Patch Set 1: %s+1", label.getName()));
+
+    // transition vote from 1 to -1
+    reviewInput = new ReviewInput().label(label.getName(), -1);
+    revision(r).review(reviewInput);
+    newVote = lastCommentAddedEvent.approvals.get()[0].value;
+    oldVote = lastCommentAddedEvent.approvals.get()[0].oldValue;
+    assertThat(oldVote).isEqualTo("1");
+    assertThat(newVote).isEqualTo("-1");
+    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+        String.format("Patch Set 1: %s-1", label.getName()));
+
+    // review with message only, do not apply votes
+    reviewInput = new ReviewInput().message(label.getName());
+    revision(r).review(reviewInput);
+    newVote = lastCommentAddedEvent.approvals.get()[0].value;
+    oldVote = lastCommentAddedEvent.approvals.get()[0].oldValue;
+    assertThat(oldVote).isEqualTo(null);  // no vote change so not included
+    assertThat(newVote).isEqualTo("-1");
+    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+        String.format("Patch Set 1:\n\n%s", label.getName()));
+  }
+
+  @Test
+  public void reviewChange_MultipleVotes() throws Exception {
+    saveLabelConfig();
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = new ReviewInput().label(label.getName(), -1);
+    reviewInput.message = label.getName();
+    revision(r).review(reviewInput);
+
+    ChangeInfo c = get(r.getChangeId());
+    LabelInfo q = c.labels.get(label.getName());
+    assertThat(q.all).hasSize(1);
+    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+        String.format("Patch Set 1: %s-1\n\n%s",
+            label.getName(), label.getName()));
+
+    reviewInput = new ReviewInput().label(pLabel.getName(), 1);
+    reviewInput.message = pLabel.getName();
+    revision(r).review(reviewInput);
+
+    c = get(r.getChangeId());
+    q = c.labels.get(label.getName());
+    assertThat(q.all).hasSize(1);
+    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+        String.format("Patch Set 1: %s+1\n\n%s",
+            pLabel.getName(), pLabel.getName()));
+
+    assertThat(lastCommentAddedEvent.approvals.get()).hasLength(2);
+    for (ApprovalAttribute approval : lastCommentAddedEvent.approvals.get()) {
+      if (approval.type.equals(label.getName())) {
+        assertThat(approval.value).isEqualTo("-1");
+        assertThat(approval.oldValue).isNull();
+      } else if (approval.type.equals(pLabel.getName())) {
+        assertThat(approval.value).isEqualTo("1");
+        assertThat(approval.oldValue).isEqualTo("0");
+      } else {
+        fail("Unexpected label: " + approval.type);
+      }
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 357f268..bdefb00 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -22,23 +22,38 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.UserScopedEventListener;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.events.CommentAddedEvent;
+import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.Util;
+import com.google.inject.Inject;
 
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 public class CustomLabelIT extends AbstractDaemonTest {
 
+  @Inject
+  private IdentifiedUser.GenericFactory factory;
+
+  @Inject
+  private DynamicSet<UserScopedEventListener> source;
+
   private final LabelType label = category("CustomLabel",
       value(1, "Positive"),
       value(0, "No score"),
@@ -48,6 +63,9 @@
       value(1, "Positive"),
       value(0, "No score"));
 
+  private CommentAddedEvent lastCommentAddedEvent;
+  private RegistrationHandle eventListenerRegistration;
+
   @Before
   public void setUp() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
@@ -58,6 +76,26 @@
     Util.allow(cfg, Permission.forLabel(P.getName()), 0, 1, anonymousUsers,
         "refs/heads/*");
     saveProjectConfig(project, cfg);
+
+    eventListenerRegistration = source.add(new UserScopedEventListener() {
+      @Override
+      public void onEvent(Event event) {
+        if (event instanceof CommentAddedEvent) {
+          lastCommentAddedEvent = (CommentAddedEvent) event;
+        }
+      }
+
+      @Override
+      public CurrentUser getUser() {
+        return factory.create(user.id);
+      }
+    });
+  }
+
+  @After
+  public void cleanup() {
+    eventListenerRegistration.remove();
+    db.close();
   }
 
   @Test
@@ -124,13 +162,18 @@
         .id(r.getChangeId())
         .addReviewer(in);
 
-    revision(r).review(new ReviewInput().label(P.getName(), 0));
+    ReviewInput input = new ReviewInput().label(P.getName(), 0);
+    input.message = "foo";
+
+    revision(r).review(input);
     ChangeInfo c = get(r.getChangeId());
     LabelInfo q = c.labels.get(P.getName());
     assertThat(q.all).hasSize(2);
     assertThat(q.disliked).isNull();
     assertThat(q.rejected).isNull();
     assertThat(q.blocking).isNull();
+    assertThat(lastCommentAddedEvent.comment).isEqualTo(
+        "Patch Set 1:\n\n" + input.message);
   }
 
   @Test
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java
index 3daeaaf..be5d1c8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java
@@ -423,7 +423,8 @@
     @Override
     public void doCommentAddedHook(final Change change, Account account,
           PatchSet patchSet, String comment, final Map<String, Short> approvals,
-          ReviewDb db) throws OrmException {
+          final Map<String, Short> oldApprovals, ReviewDb db)
+              throws OrmException {
       CommentAddedEvent event = new CommentAddedEvent(change);
       Supplier<AccountState> owner = getAccountSupplier(change.getOwner());
 
@@ -441,7 +442,8 @@
                 ApprovalAttribute[] r = new ApprovalAttribute[approvals.size()];
                 int i = 0;
                 for (Map.Entry<String, Short> approval : approvals.entrySet()) {
-                  r[i++] = getApprovalAttribute(labelTypes, approval);
+                  r[i++] = getApprovalAttribute(labelTypes, approval,
+                      oldApprovals);
                 }
                 return r;
               }
@@ -473,11 +475,17 @@
           change.getProject()).getLabelTypes();
       for (Map.Entry<String, Short> approval : approvals.entrySet()) {
         LabelType lt = labelTypes.byLabel(approval.getKey());
-        if (lt != null) {
+        if (lt != null && approval.getValue() != null) {
           addArg(args, "--" + lt.getName(), Short.toString(approval.getValue()));
+          if (oldApprovals != null && !oldApprovals.isEmpty()) {
+            Short oldValue = oldApprovals.get(approval.getKey());
+            if (oldValue != null) {
+              addArg(args, "--" + lt.getName() + "-oldValue",
+                  Short.toString(oldValue));
+            }
+          }
         }
       }
-
       runHook(change.getProject(), commentAddedHook, args);
     }
 
@@ -856,18 +864,29 @@
 
     /**
      * Create an ApprovalAttribute for the given approval suitable for serialization to JSON.
+     * @param labelTypes
      * @param approval
+     * @param oldApprovals
      * @return object suitable for serialization to JSON
      */
     private ApprovalAttribute getApprovalAttribute(LabelTypes labelTypes,
-            Entry<String, Short> approval) {
+            Entry<String, Short> approval,
+            Map<String, Short> oldApprovals) {
       ApprovalAttribute a = new ApprovalAttribute();
       a.type = approval.getKey();
+
+      if (oldApprovals != null && !oldApprovals.isEmpty()) {
+        if (oldApprovals.get(approval.getKey()) != null) {
+          a.oldValue = Short.toString(oldApprovals.get(approval.getKey()));
+        }
+      }
       LabelType lt = labelTypes.byLabel(approval.getKey());
       if (lt != null) {
         a.description = lt.getName();
       }
-      a.value = Short.toString(approval.getValue());
+      if (approval.getValue() != null) {
+        a.value = Short.toString(approval.getValue());
+      }
       return a;
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHooks.java b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHooks.java
index 8214a1c..ee1de65 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHooks.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHooks.java
@@ -60,13 +60,15 @@
    * @param account The gerrit user who added the comment.
    * @param patchSet The patchset this comment is related to.
    * @param comment The comment given.
-   * @param approvals Map of label IDs to scores.
+   * @param approvals Map of label IDs to scores
+   * @param oldApprovals Map of label IDs to old approval scores
    * @param db The review database.
    * @throws OrmException
    */
   void doCommentAddedHook(Change change, Account account,
       PatchSet patchSet, String comment,
-      Map<String, Short> approvals, ReviewDb db)
+      Map<String, Short> approvals, Map<String, Short> oldApprovals,
+      ReviewDb db)
       throws OrmException;
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/DisabledChangeHooks.java b/gerrit-server/src/main/java/com/google/gerrit/common/DisabledChangeHooks.java
index 832b3d6..bec0b7e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/DisabledChangeHooks.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/DisabledChangeHooks.java
@@ -61,7 +61,8 @@
   @Override
   public void doCommentAddedHook(Change change, Account account,
       PatchSet patchSet, String comment,
-      Map<String, Short> approvals, ReviewDb db) {
+      Map<String, Short> approvals, Map<String, Short> oldApprovals,
+      ReviewDb db) {
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
index 562d881..c8905ee 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -21,6 +21,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.extensions.api.changes.ReviewInput.NotifyHandling;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -49,6 +50,7 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.RefControl;
@@ -68,6 +70,7 @@
 
 import java.io.IOException;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -82,6 +85,7 @@
       LoggerFactory.getLogger(ChangeInserter.class);
 
   private final ProjectControl.GenericFactory projectControlFactory;
+  private final ChangeControl.GenericFactory changeControlFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
   private final ChangeHooks hooks;
@@ -121,6 +125,7 @@
 
   @Inject
   ChangeInserter(ProjectControl.GenericFactory projectControlFactory,
+      ChangeControl.GenericFactory changeControlFactory,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
       ChangeHooks hooks,
@@ -133,6 +138,7 @@
       @Assisted RevCommit commit,
       @Assisted String refName) {
     this.projectControlFactory = projectControlFactory;
+    this.changeControlFactory = changeControlFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.psUtil = psUtil;
     this.hooks = hooks;
@@ -355,7 +361,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) throws OrmException {
+  public void postUpdate(Context ctx) throws OrmException, NoSuchChangeException {
     if (sendMail) {
       Runnable sender = new Runnable() {
         @Override
@@ -386,13 +392,33 @@
       }
     }
 
+    /** For labels that are not set in this operation, show the "current" value
+     * of 0, and no oldValue as the value was not modified by this operation.
+     * For labels that are set in this operation, the value was modified, so
+     * show a transition from an oldValue of 0 to the new value.
+     **/
     if (runHooks) {
       ReviewDb db = ctx.getDb();
       hooks.doPatchsetCreatedHook(change, patchSet, db);
       if (approvals != null && !approvals.isEmpty()) {
+        ChangeControl changeControl = changeControlFactory.controlFor(
+            db, change, ctx.getUser());
+        List<LabelType> labels = changeControl.getLabelTypes().getLabelTypes();
+        Map<String, Short> allApprovals = new HashMap<>();
+        Map<String, Short> oldApprovals = new HashMap<>();
+        for (LabelType lt : labels){
+          allApprovals.put(lt.getName(), (short) 0);
+          oldApprovals.put(lt.getName(), null);
+        }
+        for (Map.Entry<String, Short> entry : approvals.entrySet()) {
+          if (entry.getValue() != 0) {
+            allApprovals.put(entry.getKey(), entry.getValue());
+            oldApprovals.put(entry.getKey(), (short) 0);
+          }
+        }
         hooks.doCommentAddedHook(change,
             ctx.getUser().asIdentifiedUser().getAccount(), patchSet, null,
-            approvals, db);
+            allApprovals, oldApprovals, db);
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
index c2c67eb..fbfcc87 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
@@ -21,6 +21,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
@@ -82,6 +83,7 @@
 
 import java.sql.Timestamp;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -350,7 +352,8 @@
     private ChangeMessage message;
     private List<PatchLineComment> comments = new ArrayList<>();
     private List<String> labelDelta = new ArrayList<>();
-    private Map<String, Short> categories = new HashMap<>();
+    private Map<String, Short> approvals = new HashMap<>();
+    private Map<String, Short> oldApprovals = new HashMap<>();
 
     private Op(PatchSet.Id psId, ReviewInput in) {
       this.psId = psId;
@@ -393,7 +396,7 @@
       }
       try {
         hooks.doCommentAddedHook(notes.getChange(), user.getAccount(), ps,
-            message.getMessage(), categories, ctx.getDb());
+            message.getMessage(), approvals, oldApprovals, ctx.getDb());
       } catch (OrmException e) {
         log.warn("ChangeHook.doCommentAddedHook delivery failed", e);
       }
@@ -514,43 +517,77 @@
       return drafts;
     }
 
+    private Map<String, Short> approvalsByKey(
+        Collection<PatchSetApproval> patchsetApprovals) {
+      Map<String, Short> labels = new HashMap<>();
+      for (PatchSetApproval psa : patchsetApprovals) {
+        labels.put(psa.getLabel(), psa.getValue());
+      }
+      return labels;
+    }
+
     private boolean updateLabels(ChangeContext ctx)
         throws OrmException, ResourceConflictException {
-      Map<String, Short> labels = in.labels;
-      if (labels == null) {
-        labels = Collections.emptyMap();
-      }
+      Map<String, Short> inLabels = MoreObjects.firstNonNull(in.labels,
+          Collections.<String, Short> emptyMap());
 
       List<PatchSetApproval> del = Lists.newArrayList();
       List<PatchSetApproval> ups = Lists.newArrayList();
       Map<String, PatchSetApproval> current = scanLabels(ctx, del);
 
+      // get all approvals in cases of quick approve vote
+      Map<String, Short> allApprovals = Collections.emptyMap();
+      if (current != null) {
+        allApprovals = approvalsByKey(current.values());
+      }
+      allApprovals.putAll(inLabels);
+
+      // get previous label votes
+      Map<String, Short> currentLabels = new HashMap<>();
+      for (Map.Entry<String, PatchSetApproval> ent : current.entrySet()) {
+        currentLabels.put(ent.getValue().getLabel(), ent.getValue().getValue());
+      }
+      Map<String, Short> previous = new HashMap<>();
+      for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
+        if (!currentLabels.containsKey(ent.getKey())) {
+          previous.put(ent.getKey(), (short)0);
+        } else {
+          previous.put(ent.getKey(), currentLabels.get(ent.getKey()));
+        }
+      }
+
       ChangeUpdate update = ctx.getUpdate(psId);
       LabelTypes labelTypes = ctx.getControl().getLabelTypes();
-      for (Map.Entry<String, Short> ent : labels.entrySet()) {
+      for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
         String name = ent.getKey();
         LabelType lt = checkNotNull(labelTypes.byLabel(name), name);
 
         PatchSetApproval c = current.remove(lt.getName());
         String normName = lt.getName();
+        approvals.put(normName, (short) 0);
         if (ent.getValue() == null || ent.getValue() == 0) {
           // User requested delete of this label.
+          oldApprovals.put(normName, null);
           if (c != null) {
             if (c.getValue() != 0) {
               addLabelDelta(normName, (short) 0);
+              oldApprovals.put(normName, previous.get(normName));
             }
             del.add(c);
-            update.putApproval(ent.getKey(), (short) 0);
+            update.putApproval(normName, (short) 0);
           }
         } else if (c != null && c.getValue() != ent.getValue()) {
           c.setValue(ent.getValue());
           c.setGranted(ctx.getWhen());
           ups.add(c);
           addLabelDelta(normName, c.getValue());
-          categories.put(normName, c.getValue());
-          update.putApproval(ent.getKey(), ent.getValue());
+          oldApprovals.put(normName, previous.get(normName));
+          approvals.put(normName, c.getValue());
+          update.putApproval(normName, ent.getValue());
         } else if (c != null && c.getValue() == ent.getValue()) {
           current.put(normName, c);
+          oldApprovals.put(normName, null);
+          approvals.put(normName, c.getValue());
         } else if (c == null) {
           c = new PatchSetApproval(new PatchSetApproval.Key(
                   psId,
@@ -560,9 +597,10 @@
           c.setGranted(ctx.getWhen());
           ups.add(c);
           addLabelDelta(normName, c.getValue());
-          categories.put(normName, c.getValue());
+          oldApprovals.put(normName, previous.get(normName));
+          approvals.put(normName, c.getValue());
           update.putReviewer(user.getAccountId(), REVIEWER);
-          update.putApproval(ent.getKey(), ent.getValue());
+          update.putApproval(normName, ent.getValue());
         }
       }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/ApprovalAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/ApprovalAttribute.java
index 3059be3..90b6fc4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/ApprovalAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/ApprovalAttribute.java
@@ -18,7 +18,7 @@
     public String type;
     public String description;
     public String value;
-
+    public String oldValue;
     public Long grantedOn;
     public AccountAttribute by;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
index 0f894ce..50d02df 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
@@ -620,6 +620,7 @@
     a.value = Short.toString(approval.getValue());
     a.by = asAccountAttribute(approval.getAccountId());
     a.grantedOn = approval.getGranted().getTime() / 1000L;
+    a.oldValue = null;
 
     LabelType lt = labelTypes.byLabel(approval.getLabelId());
     if (lt != null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
index 3de1fc1..ba9795a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
@@ -45,6 +45,8 @@
 import com.google.gerrit.server.mail.MergedSender;
 import com.google.gerrit.server.mail.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.LabelVote;
@@ -93,6 +95,7 @@
 
   private final PatchSetUtil psUtil;
   private final ChangeData.Factory changeDataFactory;
+  private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeKindCache changeKindCache;
   private final ChangeMessagesUtil cmUtil;
   private final ChangeHooks hooks;
@@ -127,6 +130,7 @@
   @AssistedInject
   ReplaceOp(PatchSetUtil psUtil,
       ChangeData.Factory changeDataFactory,
+      ChangeControl.GenericFactory changeControlFactory,
       ChangeKindCache changeKindCache,
       ChangeMessagesUtil cmUtil,
       ChangeHooks hooks,
@@ -149,6 +153,7 @@
       @Assisted @Nullable PushCertificate pushCertificate) {
     this.psUtil = psUtil;
     this.changeDataFactory = changeDataFactory;
+    this.changeControlFactory = changeControlFactory;
     this.changeKindCache = changeKindCache;
     this.cmUtil = cmUtil;
     this.hooks = hooks;
@@ -391,12 +396,45 @@
       hooks.doChangeMergedHook(change, account, newPatchSet, ctx.getDb(),
           commit.getName());
     }
-    if (!approvals.isEmpty()) {
-      hooks.doCommentAddedHook(change, account, newPatchSet, null, approvals,
-          ctx.getDb());
+    try{
+      runHook(ctx);
+    } catch (Exception e) {
+      log.warn("ChangeHook.doCommentAddedHook delivery failed", e);
     }
   }
 
+  private void runHook(final Context ctx) throws NoSuchChangeException,
+    OrmException {
+    if (approvals.isEmpty()) {
+      return;
+    }
+
+    /** For labels that are not set in this operation, show the "current" value
+     * of 0, and no oldValue as the value was not modified by this operation.
+     * For labels that are set in this operation, the value was modified, so
+     * show a transition from an oldValue of 0 to the new value.
+     **/
+    ChangeControl changeControl = changeControlFactory.controlFor(
+        ctx.getDb(), change, ctx.getUser());
+    List<LabelType> labels = changeControl.getLabelTypes().getLabelTypes();
+    Map<String, Short> allApprovals = new HashMap<>();
+    Map<String, Short> oldApprovals = new HashMap<>();
+    for (LabelType lt : labels){
+      allApprovals.put(lt.getName(), (short) 0);
+      oldApprovals.put(lt.getName(), null);
+    }
+    for (Map.Entry<String, Short> entry : approvals.entrySet()) {
+      if (entry.getValue() != 0) {
+        allApprovals.put(entry.getKey(), entry.getValue());
+        oldApprovals.put(entry.getKey(), (short) 0);
+      }
+    }
+
+    hooks.doCommentAddedHook(change,
+        ctx.getUser().asIdentifiedUser().getAccount(), newPatchSet, null,
+        allApprovals, oldApprovals, ctx.getDb());
+  }
+
   private void sendMergedEmail(final Context ctx) {
     sendEmailExecutor.submit(requestScopePropagator.wrap(new Runnable() {
       @Override