Create class to diff ChangeInfos for the REST API

Create the classes ChangeInfoDifference and ChangeInfoDiffer.
ChangeInfoDiffer takes two instances of the ChangeInfo class and returns
fields added and removed from the first given instance.

ChangeInfoDifference will be returned by a new endpoint in the REST API
that takes two meta SHA-1s and outputs the difference between the two
ChangeInfo instances associated with those SHA-1s.

Signed-off-by: Alex Spradlin <alexaspradlin@google.com>
Change-Id: Id9a3e69254f2ba41dd0b65539be40adbff035054
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;
+  }
+}