Merge "Update mockito and transitive dependencies"
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 528efe3..b5f40ce 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -110,4 +112,14 @@
   public List<PluginDefinedInfo> plugins;
   public Collection<TrackingIdInfo> trackingIds;
   public Collection<SubmitRequirementInfo> requirements;
+
+  public ChangeInfo() {}
+
+  public ChangeInfo(ChangeMessageInfo... messages) {
+    this.messages = ImmutableList.copyOf(messages);
+  }
+
+  public ChangeInfo(Map<String, RevisionInfo> revisions) {
+    this.revisions = ImmutableMap.copyOf(revisions);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
new file mode 100644
index 0000000..647dead
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
@@ -0,0 +1,191 @@
+// Copyright (C) 2021 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.extensions.common;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.Arrays.stream;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Gets the differences between two {@link ChangeInfo}s.
+ *
+ * <p>This must be in package {@code com.google.gerrit.extensions.common} for access to protected
+ * constructors.
+ *
+ * <p>This assumes that every class reachable from {@link ChangeInfo} has a non-private constructor
+ * with zero parameters and overrides the equals method.
+ */
+public final class ChangeInfoDiffer {
+
+  /**
+   * Returns the difference between two instances of {@link ChangeInfo}.
+   *
+   * <p>The {@link ChangeInfoDifference} returned has the following properties:
+   *
+   * <p>Unrepeated fields are present in the difference returned when they differ between {@code
+   * oldChangeInfo} and {@code newChangeInfo}. When there's an unrepeated field that's not a {@link
+   * String}, primitive, or enum, its fields are only returned when they differ.
+   *
+   * <p>Entries in {@link Map} fields are returned when a key is present in {@code newChangeInfo}
+   * and not {@code oldChangeInfo}. If a key is present in both, the diff of the value is returned.
+   *
+   * <p>{@link Collection} fields in {@link ChangeInfoDifference#added()} contain only items found
+   * in {@code newChangeInfo} and not {@code oldChangeInfo}.
+   *
+   * <p>{@link Collection} fields in {@link ChangeInfoDifference#removed()} contain only items found
+   * in {@code oldChangeInfo} and not {@code newChangeInfo}.
+   *
+   * @param oldChangeInfo the previous {@link ChangeInfo} to diff against {@code newChangeInfo}
+   * @param newChangeInfo the {@link ChangeInfo} to diff against {@code oldChangeInfo}
+   * @return the difference between the given {@link ChangeInfo}s
+   */
+  public static ChangeInfoDifference getDifference(
+      ChangeInfo oldChangeInfo, ChangeInfo newChangeInfo) {
+    return ChangeInfoDifference.create(
+        /* added= */ getAdded(oldChangeInfo, newChangeInfo),
+        /* removed= */ getAdded(newChangeInfo, oldChangeInfo));
+  }
+
+  @SuppressWarnings("unchecked") // reflection is used to construct instances of T
+  private static <T> T getAdded(T oldValue, T newValue) {
+    T toPopulate = (T) construct(newValue.getClass());
+    if (toPopulate == null) {
+      return null;
+    }
+
+    for (Field field : newValue.getClass().getDeclaredFields()) {
+      Object newFieldObj = get(field, newValue);
+      if (oldValue == null || newFieldObj == null) {
+        set(field, toPopulate, newFieldObj);
+        continue;
+      }
+
+      Object oldFieldObj = get(field, oldValue);
+      if (newFieldObj.equals(oldFieldObj)) {
+        continue;
+      }
+
+      if (isSimple(field.getType()) || oldFieldObj == null) {
+        set(field, toPopulate, newFieldObj);
+      } else if (newFieldObj instanceof Collection) {
+        set(
+            field,
+            toPopulate,
+            getAddedForCollection((Collection<?>) oldFieldObj, (Collection<?>) newFieldObj));
+      } else if (newFieldObj instanceof Map) {
+        set(field, toPopulate, getAddedForMap((Map<?, ?>) oldFieldObj, (Map<?, ?>) newFieldObj));
+      } else {
+        // Recurse to set all fields in the non-primitive object.
+        set(field, toPopulate, getAdded(oldFieldObj, newFieldObj));
+      }
+    }
+    return toPopulate;
+  }
+
+  @VisibleForTesting
+  static boolean isSimple(Class<?> c) {
+    return c.isPrimitive()
+        || c.isEnum()
+        || String.class.isAssignableFrom(c)
+        || Number.class.isAssignableFrom(c)
+        || Boolean.class.isAssignableFrom(c)
+        || Timestamp.class.isAssignableFrom(c);
+  }
+
+  @VisibleForTesting
+  static Object construct(Class<?> c) {
+    // Only use constructors without parameters because we can't determine what values to pass.
+    return stream(c.getDeclaredConstructors())
+        .filter(constructor -> constructor.getParameterCount() == 0)
+        .findAny()
+        .map(ChangeInfoDiffer::construct)
+        .orElseThrow(
+            () ->
+                new IllegalStateException("Class " + c + " must have a zero argument constructor"));
+  }
+
+  private static Object construct(Constructor<?> constructor) {
+    try {
+      return constructor.newInstance();
+    } catch (ReflectiveOperationException e) {
+      throw new IllegalStateException("Failed to construct class " + constructor.getName(), e);
+    }
+  }
+
+  /** @return {@code null} if nothing has been added to {@code oldCollection} */
+  private static ImmutableList<?> getAddedForCollection(
+      Collection<?> oldCollection, Collection<?> newCollection) {
+    ImmutableList<?> notInOldCollection = getAdditions(oldCollection, newCollection);
+    return notInOldCollection.isEmpty() ? null : notInOldCollection;
+  }
+
+  private static ImmutableList<Object> getAdditions(
+      Collection<?> oldCollection, Collection<?> newCollection) {
+    Map<Object, List<Object>> duplicatesMap = newCollection.stream().collect(groupingBy(v -> v));
+    oldCollection.forEach(
+        v -> {
+          if (duplicatesMap.containsKey(v)) {
+            duplicatesMap.get(v).remove(v);
+          }
+        });
+    return duplicatesMap.values().stream().flatMap(Collection::stream).collect(toImmutableList());
+  }
+
+  /** @return {@code null} if nothing has been added to {@code oldMap} */
+  private static ImmutableMap<Object, Object> getAddedForMap(Map<?, ?> oldMap, Map<?, ?> newMap) {
+    ImmutableMap.Builder<Object, Object> additionsBuilder = ImmutableMap.builder();
+    for (Map.Entry<?, ?> entry : newMap.entrySet()) {
+      Object added = getAdded(oldMap.get(entry.getKey()), entry.getValue());
+      if (added != null) {
+        additionsBuilder.put(entry.getKey(), added);
+      }
+    }
+    ImmutableMap<Object, Object> additions = additionsBuilder.build();
+    return additions.isEmpty() ? null : additions;
+  }
+
+  private static Object get(Field field, Object obj) {
+    try {
+      return field.get(obj);
+    } catch (IllegalAccessException e) {
+      throw new IllegalStateException(
+          String.format("Access denied getting field %s in %s", field.getName(), obj.getClass()),
+          e);
+    }
+  }
+
+  private static void set(Field field, Object obj, Object value) {
+    try {
+      field.set(obj, value);
+    } catch (IllegalAccessException e) {
+      throw new IllegalStateException(
+          String.format(
+              "Access denied setting field %s in %s", field.getName(), obj.getClass().getName()),
+          e);
+    }
+  }
+
+  private ChangeInfoDiffer() {}
+}
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java b/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java
new file mode 100644
index 0000000..269c673
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2021 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.extensions.common;
+
+import com.google.auto.value.AutoValue;
+
+/** The difference between two {@link ChangeInfo}s returned by {@link ChangeInfoDiffer}. */
+@AutoValue
+public abstract class ChangeInfoDifference {
+
+  public abstract ChangeInfo added();
+
+  public abstract ChangeInfo removed();
+
+  public static ChangeInfoDifference create(ChangeInfo added, ChangeInfo removed) {
+    return new AutoValue_ChangeInfoDifference(added, removed);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
index 07ad71b..10456ff 100644
--- a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
@@ -26,6 +26,12 @@
   public String message;
   public Integer _revisionNumber;
 
+  public ChangeMessageInfo() {}
+
+  public ChangeMessageInfo(String message) {
+    this.message = message;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof ChangeMessageInfo) {
diff --git a/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java
index ea61f31..f710ab7 100644
--- a/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -36,6 +36,21 @@
   public PushCertificateInfo pushCertificate;
   public String description;
 
+  public RevisionInfo() {}
+
+  public RevisionInfo(String ref) {
+    this.ref = ref;
+  }
+
+  public RevisionInfo(String ref, int number) {
+    this.ref = ref;
+    _number = number;
+  }
+
+  public RevisionInfo(AccountInfo uploader) {
+    this.uploader = uploader;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof RevisionInfo) {
diff --git a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
new file mode 100644
index 0000000..a41d63b
--- /dev/null
+++ b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
@@ -0,0 +1,355 @@
+// Copyright (C) 2021 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.extensions.common;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.lang.reflect.Field;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.Collection;
+import java.util.Map;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class ChangeInfoDifferTest {
+
+  private static final String REVISION = "abc123";
+
+  @Test
+  public void getDiff_givenEmptyChangeInfos_returnsEmptyDifference() {
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(new ChangeInfo(), new ChangeInfo());
+
+    // Spot check a few fields, including collections and maps.
+    assertThat(diff.added().branch).isNull();
+    assertThat(diff.added().project).isNull();
+    assertThat(diff.added().currentRevision).isNull();
+    assertThat(diff.added().actions).isNull();
+    assertThat(diff.added().messages).isNull();
+    assertThat(diff.added().reviewers).isNull();
+    assertThat(diff.added().hashtags).isNull();
+    assertThat(diff.removed().branch).isNull();
+    assertThat(diff.removed().project).isNull();
+    assertThat(diff.removed().currentRevision).isNull();
+    assertThat(diff.removed().actions).isNull();
+    assertThat(diff.removed().messages).isNull();
+    assertThat(diff.removed().reviewers).isNull();
+    assertThat(diff.removed().hashtags).isNull();
+  }
+
+  @Test
+  public void getDiff_givenUnchangedTopic_returnsNullTopics() {
+    ChangeInfo oldChangeInfo = createChangeInfoWithTopic("topic");
+    ChangeInfo newChangeInfo = createChangeInfoWithTopic(oldChangeInfo.topic);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().topic).isNull();
+    assertThat(diff.removed().topic).isNull();
+  }
+
+  @Test
+  public void getDiff_givenChangedTopic_returnsTopics() {
+    ChangeInfo oldChangeInfo = createChangeInfoWithTopic("old-topic");
+    ChangeInfo newChangeInfo = createChangeInfoWithTopic("new-topic");
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().topic).isEqualTo(newChangeInfo.topic);
+    assertThat(diff.removed().topic).isEqualTo(oldChangeInfo.topic);
+  }
+
+  @Test
+  public void getDiff_givenEqualAssignees_returnsNullAssignee() {
+    ChangeInfo oldChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
+    ChangeInfo newChangeInfo =
+        createChangeInfoWithAccount(
+            new AccountInfo(oldChangeInfo.assignee.name, oldChangeInfo.assignee.email));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().assignee).isNull();
+    assertThat(diff.removed().assignee).isNull();
+  }
+
+  @Test
+  public void getDiff_givenNewAssignee_returnsAssignee() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().assignee).isEqualTo(newChangeInfo.assignee);
+    assertThat(diff.removed().assignee).isNull();
+  }
+
+  @Test
+  public void getDiff_withRemovedAssignee_returnsAssignee() {
+    ChangeInfo oldChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
+    ChangeInfo newChangeInfo = new ChangeInfo();
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().assignee).isNull();
+    assertThat(diff.removed().assignee).isEqualTo(oldChangeInfo.assignee);
+  }
+
+  @Test
+  public void getDiff_givenAssigneeWithNewName_returnsNameButNotEmail() {
+    ChangeInfo oldChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("old name", "mail@mail.com"));
+    ChangeInfo newChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("new name", oldChangeInfo.assignee.email));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().assignee).isNotNull();
+    assertThat(diff.added().assignee.name).isEqualTo(newChangeInfo.assignee.name);
+    assertThat(diff.added().assignee.email).isNull();
+    assertThat(diff.removed().assignee).isNotNull();
+    assertThat(diff.removed().assignee.name).isEqualTo(oldChangeInfo.assignee.name);
+    assertThat(diff.removed().assignee.email).isNull();
+  }
+
+  @Test
+  public void getDiff_whenHashtagsChanged_returnsHashtags() {
+    String removedHashtag = "removed";
+    String addedHashtag = "added";
+    ChangeInfo oldChangeInfo = createChangeInfoWithHashtags(removedHashtag, "existing");
+    ChangeInfo newChangeInfo = createChangeInfoWithHashtags("existing", addedHashtag);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().hashtags).isNotNull();
+    assertThat(diff.added().hashtags).containsExactly(addedHashtag);
+    assertThat(diff.removed().hashtags).isNotNull();
+    assertThat(diff.removed().hashtags).containsExactly(removedHashtag);
+  }
+
+  @Test
+  public void getDiff_whenDuplicateHashtagAdded_returnsHashtag() {
+    String hashtag = "hashtag";
+    ChangeInfo oldChangeInfo = createChangeInfoWithHashtags(hashtag, hashtag);
+    ChangeInfo newChangeInfo = createChangeInfoWithHashtags(hashtag, hashtag, hashtag);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().hashtags).isNotNull();
+    assertThat(diff.added().hashtags).containsExactly(hashtag);
+    assertThat(diff.removed().hashtags).isNull();
+  }
+
+  @Test
+  public void getDiff_whenChangeMessageUnchanged_returnsNullMessage() {
+    String message = "message";
+    ChangeInfo oldChangeInfo = new ChangeInfo(new ChangeMessageInfo(message));
+    ChangeInfo newChangeInfo = new ChangeInfo(new ChangeMessageInfo(message));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().messages).isNull();
+    assertThat(diff.removed().messages).isNull();
+  }
+
+  @Test
+  public void getDiff_whenChangeMessageAdded_returnsAdded() {
+    ChangeMessageInfo addedMessage = new ChangeMessageInfo("added");
+    ChangeMessageInfo existingMessage = new ChangeMessageInfo("existing");
+    ChangeInfo oldChangeInfo = new ChangeInfo(existingMessage);
+    ChangeInfo newChangeInfo = new ChangeInfo(existingMessage, addedMessage);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().messages).isNotNull();
+    assertThat(diff.added().messages).containsExactly(addedMessage);
+    assertThat(diff.removed().messages).isNull();
+  }
+
+  @Test
+  public void getDiff_whenChangeMessageRemoved_returnsRemoved() {
+    ChangeMessageInfo removedMessage = new ChangeMessageInfo("removed");
+    ChangeMessageInfo existingMessage = new ChangeMessageInfo("existing");
+    ChangeInfo oldChangeInfo = new ChangeInfo(existingMessage, removedMessage);
+    ChangeInfo newChangeInfo = new ChangeInfo(existingMessage);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().messages).isNull();
+    assertThat(diff.removed().messages).isNotNull();
+    assertThat(diff.removed().messages).containsExactly(removedMessage);
+  }
+
+  @Test
+  public void getDiff_whenDuplicateMessagesAdded_returnsDuplicates() {
+    ChangeMessageInfo message = new ChangeMessageInfo("message");
+    ChangeInfo oldChangeInfo = new ChangeInfo(message, message);
+    ChangeInfo newChangeInfo = new ChangeInfo(message, message, message, message);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().messages).isNotNull();
+    assertThat(diff.added().messages).containsExactly(message, message);
+    assertThat(diff.removed().messages).isNull();
+  }
+
+  @Test
+  public void getDiff_whenNoNewRevisions_returnsNullRevisions() {
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, new RevisionInfo("ref")));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, new RevisionInfo("ref")));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNull();
+    assertThat(diff.removed().revisions).isNull();
+  }
+
+  @Test
+  public void getDiff_whenOneAddedRevision_returnsRevision() {
+    RevisionInfo addedRevision = new RevisionInfo("ref");
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of());
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, addedRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNotNull();
+    assertThat(diff.added().revisions).hasSize(1);
+    assertThat(diff.added().revisions).containsKey(REVISION);
+    assertThat(diff.added().revisions.get(REVISION).ref).isEqualTo(addedRevision.ref);
+    assertThat(diff.removed().revisions).isNull();
+  }
+
+  @Test
+  public void getDiff_whenOneModifiedRevision_returnsModificationsToRevision() {
+    RevisionInfo oldRevision = new RevisionInfo("ref", 1);
+    RevisionInfo newRevision = new RevisionInfo(oldRevision.ref, 2);
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, oldRevision));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, newRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNotNull();
+    assertThat(diff.added().revisions).hasSize(1);
+    assertThat(diff.added().revisions).containsKey(REVISION);
+    assertThat(diff.added().revisions.get(REVISION).ref).isNull();
+    assertThat(diff.added().revisions.get(REVISION)._number).isEqualTo(newRevision._number);
+    assertThat(diff.removed().revisions).isNotNull();
+    assertThat(diff.removed().revisions).hasSize(1);
+    assertThat(diff.removed().revisions).containsKey(REVISION);
+    assertThat(diff.removed().revisions.get(REVISION).ref).isNull();
+    assertThat(diff.removed().revisions.get(REVISION)._number).isEqualTo(oldRevision._number);
+  }
+
+  @Test
+  public void getDiff_whenOneModifiedRevisionUploader_returnsModificationsToRevisionUploader() {
+    RevisionInfo oldRevision = new RevisionInfo(new AccountInfo("name", "email@mail.com"));
+    RevisionInfo newRevision =
+        new RevisionInfo(
+            new AccountInfo(oldRevision.uploader.name, oldRevision.uploader.email + "2"));
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, oldRevision));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, newRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNotNull();
+    assertThat(diff.added().revisions).hasSize(1);
+    assertThat(diff.added().revisions).containsKey(REVISION);
+    assertThat(diff.added().revisions.get(REVISION).uploader).isNotNull();
+    assertThat(diff.added().revisions.get(REVISION).uploader.name).isNull();
+    assertThat(diff.added().revisions.get(REVISION).uploader.email)
+        .isEqualTo(newRevision.uploader.email);
+    assertThat(diff.removed().revisions).isNotNull();
+    assertThat(diff.removed().revisions).hasSize(1);
+    assertThat(diff.removed().revisions).containsKey(REVISION);
+    assertThat(diff.removed().revisions.get(REVISION).uploader).isNotNull();
+    assertThat(diff.removed().revisions.get(REVISION).uploader.name).isNull();
+    assertThat(diff.removed().revisions.get(REVISION).uploader.email)
+        .isEqualTo(oldRevision.uploader.email);
+  }
+
+  @Test
+  public void getDiff_whenOneUnchangedRevisionUploader_returnsNullRevision() {
+    RevisionInfo oldRevision = new RevisionInfo(new AccountInfo("name", "email@mail.com"));
+    RevisionInfo newRevision = new RevisionInfo(oldRevision.uploader);
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, oldRevision));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, newRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNull();
+    assertThat(diff.removed().revisions).isNull();
+  }
+
+  @Test
+  public void getDiff_assertCanConstructAllChangeInfoReferences() throws Exception {
+    buildObjectWithFullFields(ChangeInfo.class);
+  }
+
+  private static Object buildObjectWithFullFields(Class<?> c) throws Exception {
+    if (c == null) {
+      return null;
+    }
+    Object toPopulate = ChangeInfoDiffer.construct(c);
+    for (Field field : toPopulate.getClass().getDeclaredFields()) {
+      Class<?> parameterizedType = getParameterizedType(field);
+      if (!ChangeInfoDiffer.isSimple(field.getType())
+          && !field.getType().isArray()
+          && !Map.class.isAssignableFrom(field.getType())
+          && !Collection.class.isAssignableFrom(field.getType())) {
+        field.set(toPopulate, buildObjectWithFullFields(field.getType()));
+      } else if (Collection.class.isAssignableFrom(field.getType())
+          && parameterizedType != null
+          && !ChangeInfoDiffer.isSimple(parameterizedType)) {
+        field.set(toPopulate, ImmutableList.of(buildObjectWithFullFields(parameterizedType)));
+      }
+    }
+    return toPopulate;
+  }
+
+  private static Class<?> getParameterizedType(Field field) {
+    if (!Collection.class.isAssignableFrom(field.getType())) {
+      return null;
+    }
+    Type genericType = field.getGenericType();
+    if (genericType instanceof ParameterizedType) {
+      return (Class<?>) ((ParameterizedType) genericType).getActualTypeArguments()[0];
+    }
+    return null;
+  }
+
+  private static ChangeInfo createChangeInfoWithTopic(String topic) {
+    ChangeInfo changeInfo = new ChangeInfo();
+    changeInfo.topic = topic;
+    return changeInfo;
+  }
+
+  private static ChangeInfo createChangeInfoWithAccount(AccountInfo accountInfo) {
+    ChangeInfo changeInfo = new ChangeInfo();
+    changeInfo.assignee = accountInfo;
+    return changeInfo;
+  }
+
+  private static ChangeInfo createChangeInfoWithHashtags(String... hashtags) {
+    ChangeInfo changeInfo = new ChangeInfo();
+    changeInfo.hashtags = ImmutableList.copyOf(hashtags);
+    return changeInfo;
+  }
+}