summaryrefslogtreecommitdiffstats
path: root/java/com/google/gerrit/server/notedb/ChangeBundle.java
diff options
context:
space:
mode:
Diffstat (limited to 'java/com/google/gerrit/server/notedb/ChangeBundle.java')
-rw-r--r--java/com/google/gerrit/server/notedb/ChangeBundle.java973
1 files changed, 973 insertions, 0 deletions
diff --git a/java/com/google/gerrit/server/notedb/ChangeBundle.java b/java/com/google/gerrit/server/notedb/ChangeBundle.java
new file mode 100644
index 0000000000..058e5e5785
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/ChangeBundle.java
@@ -0,0 +1,973 @@
+// 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.server.notedb;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSortedMap.toImmutableSortedMap;
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.checkColumns;
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.intKeyOrdering;
+import static com.google.gerrit.server.notedb.ChangeBundle.Source.NOTE_DB;
+import static com.google.gerrit.server.notedb.ChangeBundle.Source.REVIEW_DB;
+import static com.google.gerrit.server.util.time.TimeUtil.truncateToSecond;
+import static java.util.Comparator.comparing;
+import static java.util.Comparator.naturalOrder;
+import static java.util.Comparator.nullsFirst;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Function;
+import com.google.common.base.Predicate;
+import com.google.common.base.Predicates;
+import com.google.common.base.Strings;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableCollection;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Ordering;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSet.Id;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
+import com.google.gwtorm.client.Column;
+import com.google.gwtorm.server.OrmException;
+import java.lang.reflect.Field;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * A bundle of all entities rooted at a single {@link Change} entity.
+ *
+ * <p>See the {@link Change} Javadoc for a depiction of this tree. Bundles may be compared using
+ * {@link #differencesFrom(ChangeBundle)}, which normalizes out the minor implementation differences
+ * between ReviewDb and NoteDb.
+ */
+public class ChangeBundle {
+ public enum Source {
+ REVIEW_DB,
+ NOTE_DB;
+ }
+
+ public static ChangeBundle fromNotes(CommentsUtil commentsUtil, ChangeNotes notes)
+ throws OrmException {
+ return new ChangeBundle(
+ notes.getChange(),
+ notes.getChangeMessages(),
+ notes.getPatchSets().values(),
+ notes.getApprovals().values(),
+ Iterables.concat(
+ CommentsUtil.toPatchLineComments(
+ notes.getChangeId(),
+ PatchLineComment.Status.DRAFT,
+ commentsUtil.draftByChange(null, notes)),
+ CommentsUtil.toPatchLineComments(
+ notes.getChangeId(),
+ PatchLineComment.Status.PUBLISHED,
+ commentsUtil.publishedByChange(null, notes))),
+ notes.getReviewers(),
+ Source.NOTE_DB);
+ }
+
+ private static ImmutableSortedMap<ChangeMessage.Key, ChangeMessage> changeMessageMap(
+ Collection<ChangeMessage> in) {
+ return in.stream()
+ .collect(
+ toImmutableSortedMap(
+ comparing((ChangeMessage.Key k) -> k.getParentKey().get())
+ .thenComparing(k -> k.get()),
+ cm -> cm.getKey(),
+ cm -> cm));
+ }
+
+ // Unlike the *Map comparators, which are intended to make key lists diffable,
+ // this comparator sorts first on timestamp, then on every other field.
+ private static final Comparator<ChangeMessage> CHANGE_MESSAGE_COMPARATOR =
+ comparing(ChangeMessage::getWrittenOn)
+ .thenComparing(m -> m.getKey().getParentKey().get())
+ .thenComparing(
+ m -> m.getPatchSetId() != null ? m.getPatchSetId().get() : null,
+ nullsFirst(naturalOrder()))
+ .thenComparing(ChangeMessage::getAuthor, intKeyOrdering())
+ .thenComparing(ChangeMessage::getMessage, nullsFirst(naturalOrder()));
+
+ private static ImmutableList<ChangeMessage> changeMessageList(Iterable<ChangeMessage> in) {
+ return Streams.stream(in).sorted(CHANGE_MESSAGE_COMPARATOR).collect(toImmutableList());
+ }
+
+ private static ImmutableSortedMap<Id, PatchSet> patchSetMap(Iterable<PatchSet> in) {
+ return Streams.stream(in)
+ .collect(toImmutableSortedMap(patchSetIdComparator(), PatchSet::getId, ps -> ps));
+ }
+
+ private static ImmutableSortedMap<PatchSetApproval.Key, PatchSetApproval> patchSetApprovalMap(
+ Iterable<PatchSetApproval> in) {
+ return Streams.stream(in)
+ .collect(
+ toImmutableSortedMap(
+ comparing(PatchSetApproval.Key::getParentKey, patchSetIdComparator())
+ .thenComparing(PatchSetApproval.Key::getAccountId, intKeyOrdering())
+ .thenComparing(PatchSetApproval.Key::getLabelId),
+ PatchSetApproval::getKey,
+ a -> a));
+ }
+
+ private static ImmutableSortedMap<PatchLineComment.Key, PatchLineComment> patchLineCommentMap(
+ Iterable<PatchLineComment> in) {
+ return Streams.stream(in)
+ .collect(
+ toImmutableSortedMap(
+ comparing(
+ (PatchLineComment.Key k) -> k.getParentKey().getParentKey(),
+ patchSetIdComparator())
+ .thenComparing(PatchLineComment.Key::getParentKey)
+ .thenComparing(PatchLineComment.Key::get),
+ PatchLineComment::getKey,
+ c -> c));
+ }
+
+ private static Comparator<PatchSet.Id> patchSetIdComparator() {
+ return comparing((PatchSet.Id id) -> id.getParentKey().get()).thenComparing(id -> id.get());
+ }
+
+ static {
+ // Initialization-time checks that the column set hasn't changed since the
+ // last time this file was updated.
+ checkColumns(Change.Id.class, 1);
+
+ checkColumns(
+ Change.class, 1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18, 19, 20, 21, 22, 23, 101);
+ checkColumns(ChangeMessage.Key.class, 1, 2);
+ checkColumns(ChangeMessage.class, 1, 2, 3, 4, 5, 6, 7);
+ checkColumns(PatchSet.Id.class, 1, 2);
+ checkColumns(PatchSet.class, 1, 2, 3, 4, 6, 8, 9);
+ checkColumns(PatchSetApproval.Key.class, 1, 2, 3);
+ checkColumns(PatchSetApproval.class, 1, 2, 3, 6, 7, 8);
+ checkColumns(PatchLineComment.Key.class, 1, 2);
+ checkColumns(PatchLineComment.class, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12);
+ }
+
+ private final Change change;
+ private final ImmutableList<ChangeMessage> changeMessages;
+ private final ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
+ private final ImmutableMap<PatchSetApproval.Key, PatchSetApproval> patchSetApprovals;
+ private final ImmutableMap<PatchLineComment.Key, PatchLineComment> patchLineComments;
+ private final ReviewerSet reviewers;
+ private final Source source;
+
+ public ChangeBundle(
+ Change change,
+ Iterable<ChangeMessage> changeMessages,
+ Iterable<PatchSet> patchSets,
+ Iterable<PatchSetApproval> patchSetApprovals,
+ Iterable<PatchLineComment> patchLineComments,
+ ReviewerSet reviewers,
+ Source source) {
+ this.change = requireNonNull(change);
+ this.changeMessages = changeMessageList(changeMessages);
+ this.patchSets = ImmutableSortedMap.copyOfSorted(patchSetMap(patchSets));
+ this.patchSetApprovals = ImmutableMap.copyOf(patchSetApprovalMap(patchSetApprovals));
+ this.patchLineComments = ImmutableMap.copyOf(patchLineCommentMap(patchLineComments));
+ this.reviewers = requireNonNull(reviewers);
+ this.source = requireNonNull(source);
+
+ for (ChangeMessage m : this.changeMessages) {
+ checkArgument(m.getKey().getParentKey().equals(change.getId()));
+ }
+ for (PatchSet.Id id : this.patchSets.keySet()) {
+ checkArgument(id.getParentKey().equals(change.getId()));
+ }
+ for (PatchSetApproval.Key k : this.patchSetApprovals.keySet()) {
+ checkArgument(k.getParentKey().getParentKey().equals(change.getId()));
+ }
+ for (PatchLineComment.Key k : this.patchLineComments.keySet()) {
+ checkArgument(k.getParentKey().getParentKey().getParentKey().equals(change.getId()));
+ }
+ }
+
+ public Change getChange() {
+ return change;
+ }
+
+ public ImmutableCollection<ChangeMessage> getChangeMessages() {
+ return changeMessages;
+ }
+
+ public ImmutableCollection<PatchSet> getPatchSets() {
+ return patchSets.values();
+ }
+
+ public ImmutableCollection<PatchSetApproval> getPatchSetApprovals() {
+ return patchSetApprovals.values();
+ }
+
+ public ImmutableCollection<PatchLineComment> getPatchLineComments() {
+ return patchLineComments.values();
+ }
+
+ public ReviewerSet getReviewers() {
+ return reviewers;
+ }
+
+ public Source getSource() {
+ return source;
+ }
+
+ public ImmutableList<String> differencesFrom(ChangeBundle o) {
+ List<String> diffs = new ArrayList<>();
+ diffChanges(diffs, this, o);
+ diffChangeMessages(diffs, this, o);
+ diffPatchSets(diffs, this, o);
+ diffPatchSetApprovals(diffs, this, o);
+ diffReviewers(diffs, this, o);
+ diffPatchLineComments(diffs, this, o);
+ return ImmutableList.copyOf(diffs);
+ }
+
+ private Timestamp getFirstPatchSetTime() {
+ if (patchSets.isEmpty()) {
+ return change.getCreatedOn();
+ }
+ return patchSets.firstEntry().getValue().getCreatedOn();
+ }
+
+ private Timestamp getLatestTimestamp() {
+ Ordering<Timestamp> o = Ordering.natural().nullsFirst();
+ Timestamp ts = null;
+ for (ChangeMessage cm : filterChangeMessages()) {
+ ts = o.max(ts, cm.getWrittenOn());
+ }
+ for (PatchSet ps : getPatchSets()) {
+ ts = o.max(ts, ps.getCreatedOn());
+ }
+ for (PatchSetApproval psa : filterPatchSetApprovals().values()) {
+ ts = o.max(ts, psa.getGranted());
+ }
+ for (PatchLineComment plc : filterPatchLineComments().values()) {
+ // Ignore draft comments, as they do not show up in the change meta graph.
+ if (plc.getStatus() != PatchLineComment.Status.DRAFT) {
+ ts = o.max(ts, plc.getWrittenOn());
+ }
+ }
+ return firstNonNull(ts, change.getLastUpdatedOn());
+ }
+
+ private Map<PatchSetApproval.Key, PatchSetApproval> filterPatchSetApprovals() {
+ return limitToValidPatchSets(patchSetApprovals, PatchSetApproval.Key::getParentKey);
+ }
+
+ private Map<PatchLineComment.Key, PatchLineComment> filterPatchLineComments() {
+ return limitToValidPatchSets(patchLineComments, k -> k.getParentKey().getParentKey());
+ }
+
+ private <K, V> Map<K, V> limitToValidPatchSets(Map<K, V> in, Function<K, PatchSet.Id> func) {
+ return Maps.filterKeys(in, Predicates.compose(validPatchSetPredicate(), func));
+ }
+
+ private Predicate<PatchSet.Id> validPatchSetPredicate() {
+ return patchSets::containsKey;
+ }
+
+ private Collection<ChangeMessage> filterChangeMessages() {
+ final Predicate<PatchSet.Id> validPatchSet = validPatchSetPredicate();
+ return Collections2.filter(
+ changeMessages,
+ m -> {
+ PatchSet.Id psId = m.getPatchSetId();
+ if (psId == null) {
+ return true;
+ }
+ return validPatchSet.apply(psId);
+ });
+ }
+
+ private static void diffChanges(List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
+ Change a = bundleA.change;
+ Change b = bundleB.change;
+ String desc = a.getId().equals(b.getId()) ? describe(a.getId()) : "Changes";
+
+ boolean excludeCreatedOn = false;
+ boolean excludeCurrentPatchSetId = false;
+ boolean excludeTopic = false;
+ Timestamp aCreated = a.getCreatedOn();
+ Timestamp bCreated = b.getCreatedOn();
+ Timestamp aUpdated = a.getLastUpdatedOn();
+ Timestamp bUpdated = b.getLastUpdatedOn();
+
+ boolean excludeSubject = false;
+ boolean excludeOrigSubj = false;
+ // Subject is not technically a nullable field, but we observed some null
+ // subjects in the wild on googlesource.com, so treat null as empty.
+ String aSubj = Strings.nullToEmpty(a.getSubject());
+ String bSubj = Strings.nullToEmpty(b.getSubject());
+
+ // Allow created timestamp in NoteDb to be any of:
+ // - The created timestamp of the change.
+ // - The timestamp of the first remaining patch set.
+ // - The last updated timestamp, if it is less than the created timestamp.
+ //
+ // Ignore subject if the NoteDb subject starts with the ReviewDb subject.
+ // The NoteDb subject is read directly from the commit, whereas the ReviewDb
+ // subject historically may have been truncated to fit in a SQL varchar
+ // column.
+ //
+ // Ignore original subject on the ReviewDb side when comparing to NoteDb.
+ // This field may have any number of values:
+ // - It may be null, if the change has had no new patch sets pushed since
+ // migrating to schema 103.
+ // - It may match the first patch set subject, if the change was created
+ // after migrating to schema 103.
+ // - It may match the subject of the first patch set that was pushed after
+ // the migration to schema 103, even though that is neither the subject
+ // of the first patch set nor the subject of the last patch set. (See
+ // Change#setCurrentPatchSet as of 43b10f86 for this behavior.) This
+ // subject of an intermediate patch set is not available to the
+ // ChangeBundle; we would have to get the subject from the repo, which is
+ // inconvenient at this point.
+ //
+ // Ignore original subject on the ReviewDb side if it equals the subject of
+ // the current patch set.
+ //
+ // For all of the above subject comparisons, first trim any leading spaces
+ // from the NoteDb strings. (We actually do represent the leading spaces
+ // faithfully during conversion, but JGit's FooterLine parser trims them
+ // when reading.)
+ //
+ // Ignore empty topic on the ReviewDb side if it is null on the NoteDb side.
+ //
+ // Ignore currentPatchSetId on NoteDb side if ReviewDb does not point to a
+ // valid patch set.
+ //
+ // Use max timestamp of all ReviewDb entities when comparing with NoteDb.
+ if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
+ boolean createdOnMatchesFirstPs =
+ !timestampsDiffer(bundleA, bundleA.getFirstPatchSetTime(), bundleB, bCreated);
+ boolean createdOnMatchesLastUpdatedOn =
+ !timestampsDiffer(bundleA, aUpdated, bundleB, bCreated);
+ boolean createdAfterUpdated = aCreated.compareTo(aUpdated) > 0;
+ excludeCreatedOn =
+ createdOnMatchesFirstPs || (createdAfterUpdated && createdOnMatchesLastUpdatedOn);
+
+ aSubj = cleanReviewDbSubject(aSubj);
+ bSubj = cleanNoteDbSubject(bSubj);
+ excludeCurrentPatchSetId = !bundleA.validPatchSetPredicate().apply(a.currentPatchSetId());
+ excludeSubject = bSubj.startsWith(aSubj) || excludeCurrentPatchSetId;
+ excludeOrigSubj = true;
+ String aTopic = trimOrNull(a.getTopic());
+ excludeTopic =
+ Objects.equals(aTopic, b.getTopic()) || ("".equals(aTopic) && b.getTopic() == null);
+ aUpdated = bundleA.getLatestTimestamp();
+ } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
+ boolean createdOnMatchesFirstPs =
+ !timestampsDiffer(bundleA, aCreated, bundleB, bundleB.getFirstPatchSetTime());
+ boolean createdOnMatchesLastUpdatedOn =
+ !timestampsDiffer(bundleA, aCreated, bundleB, bUpdated);
+ boolean createdAfterUpdated = bCreated.compareTo(bUpdated) > 0;
+ excludeCreatedOn =
+ createdOnMatchesFirstPs || (createdAfterUpdated && createdOnMatchesLastUpdatedOn);
+
+ aSubj = cleanNoteDbSubject(aSubj);
+ bSubj = cleanReviewDbSubject(bSubj);
+ excludeCurrentPatchSetId = !bundleB.validPatchSetPredicate().apply(b.currentPatchSetId());
+ excludeSubject = aSubj.startsWith(bSubj) || excludeCurrentPatchSetId;
+ excludeOrigSubj = true;
+ String bTopic = trimOrNull(b.getTopic());
+ excludeTopic =
+ Objects.equals(bTopic, a.getTopic()) || (a.getTopic() == null && "".equals(bTopic));
+ bUpdated = bundleB.getLatestTimestamp();
+ }
+
+ String subjectField = "subject";
+ String updatedField = "lastUpdatedOn";
+ List<String> exclude =
+ Lists.newArrayList(subjectField, updatedField, "noteDbState", "rowVersion");
+ if (excludeCreatedOn) {
+ exclude.add("createdOn");
+ }
+ if (excludeCurrentPatchSetId) {
+ exclude.add("currentPatchSetId");
+ }
+ if (excludeOrigSubj) {
+ exclude.add("originalSubject");
+ }
+ if (excludeTopic) {
+ exclude.add("topic");
+ }
+ diffColumnsExcluding(diffs, Change.class, desc, bundleA, a, bundleB, b, exclude);
+
+ // Allow last updated timestamps to either be exactly equal (within slop),
+ // or the NoteDb timestamp to be equal to the latest entity timestamp in the
+ // whole ReviewDb bundle (within slop).
+ if (timestampsDiffer(bundleA, a.getLastUpdatedOn(), bundleB, b.getLastUpdatedOn())) {
+ diffTimestamps(
+ diffs, desc, bundleA, aUpdated, bundleB, bUpdated, "effective last updated time");
+ }
+ if (!excludeSubject) {
+ diffValues(diffs, desc, aSubj, bSubj, subjectField);
+ }
+ }
+
+ private static String trimOrNull(String s) {
+ return s != null ? CharMatcher.whitespace().trimFrom(s) : null;
+ }
+
+ private static String cleanReviewDbSubject(String s) {
+ s = CharMatcher.is(' ').trimLeadingFrom(s);
+
+ // An old JGit bug failed to extract subjects from commits with "\r\n"
+ // terminators: https://bugs.eclipse.org/bugs/show_bug.cgi?id=400707
+ // Changes created with this bug may have "\r\n" converted to "\r " and the
+ // entire commit in the subject. The version of JGit used to read NoteDb
+ // changes parses these subjects correctly, so we need to clean up old
+ // ReviewDb subjects before comparing.
+ int rn = s.indexOf("\r \r ");
+ if (rn >= 0) {
+ s = s.substring(0, rn);
+ }
+ return NoteDbUtil.sanitizeFooter(s);
+ }
+
+ private static String cleanNoteDbSubject(String s) {
+ return NoteDbUtil.sanitizeFooter(s);
+ }
+
+ /**
+ * Set of fields that must always exactly match between ReviewDb and NoteDb.
+ *
+ * <p>Used to limit the worst-case quadratic search when pairing off matching messages below.
+ */
+ @AutoValue
+ abstract static class ChangeMessageCandidate {
+ static ChangeMessageCandidate create(ChangeMessage cm) {
+ return new AutoValue_ChangeBundle_ChangeMessageCandidate(
+ cm.getAuthor(), cm.getMessage(), cm.getTag());
+ }
+
+ @Nullable
+ abstract Account.Id author();
+
+ @Nullable
+ abstract String message();
+
+ @Nullable
+ abstract String tag();
+
+ // Exclude:
+ // - patch set, which may be null on ReviewDb side but not NoteDb
+ // - UUID, which is always different between ReviewDb and NoteDb
+ // - writtenOn, which is fuzzy
+ }
+
+ private static void diffChangeMessages(
+ List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
+ if (bundleA.source == REVIEW_DB && bundleB.source == REVIEW_DB) {
+ // Both came from ReviewDb: check all fields exactly.
+ Map<ChangeMessage.Key, ChangeMessage> as = changeMessageMap(bundleA.filterChangeMessages());
+ Map<ChangeMessage.Key, ChangeMessage> bs = changeMessageMap(bundleB.filterChangeMessages());
+
+ for (ChangeMessage.Key k : diffKeySets(diffs, as, bs)) {
+ ChangeMessage a = as.get(k);
+ ChangeMessage b = bs.get(k);
+ String desc = describe(k);
+ diffColumns(diffs, ChangeMessage.class, desc, bundleA, a, bundleB, b);
+ }
+ return;
+ }
+ Change.Id id = bundleA.getChange().getId();
+ checkArgument(id.equals(bundleB.getChange().getId()));
+
+ // Try to pair up matching ChangeMessages from each side, and succeed only
+ // if both collections are empty at the end. Quadratic in the worst case,
+ // but easy to reason about.
+ List<ChangeMessage> as = new LinkedList<>(bundleA.filterChangeMessages());
+
+ ListMultimap<ChangeMessageCandidate, ChangeMessage> bs = LinkedListMultimap.create();
+ for (ChangeMessage b : bundleB.filterChangeMessages()) {
+ bs.put(ChangeMessageCandidate.create(b), b);
+ }
+
+ Iterator<ChangeMessage> ait = as.iterator();
+ A:
+ while (ait.hasNext()) {
+ ChangeMessage a = ait.next();
+ Iterator<ChangeMessage> bit = bs.get(ChangeMessageCandidate.create(a)).iterator();
+ while (bit.hasNext()) {
+ ChangeMessage b = bit.next();
+ if (changeMessagesMatch(bundleA, a, bundleB, b)) {
+ ait.remove();
+ bit.remove();
+ continue A;
+ }
+ }
+ }
+
+ if (as.isEmpty() && bs.isEmpty()) {
+ return;
+ }
+ StringBuilder sb =
+ new StringBuilder("ChangeMessages differ for Change.Id ").append(id).append('\n');
+ if (!as.isEmpty()) {
+ sb.append("Only in A:");
+ for (ChangeMessage cm : as) {
+ sb.append("\n ").append(cm);
+ }
+ if (!bs.isEmpty()) {
+ sb.append('\n');
+ }
+ }
+ if (!bs.isEmpty()) {
+ sb.append("Only in B:");
+ bs.values().stream()
+ .sorted(CHANGE_MESSAGE_COMPARATOR)
+ .forEach(cm -> sb.append("\n ").append(cm));
+ }
+ diffs.add(sb.toString());
+ }
+
+ private static boolean changeMessagesMatch(
+ ChangeBundle bundleA, ChangeMessage a, ChangeBundle bundleB, ChangeMessage b) {
+ List<String> tempDiffs = new ArrayList<>();
+ String temp = "temp";
+
+ // ReviewDb allows timestamps before patch set was created, but NoteDb
+ // truncates this to the patch set creation timestamp.
+ Timestamp ta = a.getWrittenOn();
+ Timestamp tb = b.getWrittenOn();
+ PatchSet psa = bundleA.patchSets.get(a.getPatchSetId());
+ PatchSet psb = bundleB.patchSets.get(b.getPatchSetId());
+ boolean excludePatchSet = false;
+ boolean excludeWrittenOn = false;
+ if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
+ excludePatchSet = a.getPatchSetId() == null;
+ excludeWrittenOn =
+ psa != null
+ && psb != null
+ && ta.before(psa.getCreatedOn())
+ && tb.equals(psb.getCreatedOn());
+ } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
+ excludePatchSet = b.getPatchSetId() == null;
+ excludeWrittenOn =
+ psa != null
+ && psb != null
+ && tb.before(psb.getCreatedOn())
+ && ta.equals(psa.getCreatedOn());
+ }
+
+ List<String> exclude = Lists.newArrayList("key");
+ if (excludePatchSet) {
+ exclude.add("patchset");
+ }
+ if (excludeWrittenOn) {
+ exclude.add("writtenOn");
+ }
+
+ diffColumnsExcluding(tempDiffs, ChangeMessage.class, temp, bundleA, a, bundleB, b, exclude);
+ return tempDiffs.isEmpty();
+ }
+
+ private static void diffPatchSets(
+ List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
+ Map<PatchSet.Id, PatchSet> as = bundleA.patchSets;
+ Map<PatchSet.Id, PatchSet> bs = bundleB.patchSets;
+ Optional<PatchSet.Id> minA = as.keySet().stream().min(intKeyOrdering());
+ Optional<PatchSet.Id> minB = bs.keySet().stream().min(intKeyOrdering());
+ Set<PatchSet.Id> ids = diffKeySets(diffs, as, bs);
+
+ // Old versions of Gerrit had a bug that created patch sets during
+ // rebase or submission with a createdOn timestamp earlier than the patch
+ // set it was replacing. (In the cases I examined, it was equal to createdOn
+ // for the change, but we're not counting on this exact behavior.)
+ //
+ // ChangeRebuilder ensures patch set events come out in order, but it's hard
+ // to predict what the resulting timestamps would look like. So, completely
+ // ignore the createdOn timestamps if both:
+ // * ReviewDb timestamps are non-monotonic.
+ // * NoteDb timestamps are monotonic.
+ //
+ // Allow the timestamp of the first patch set to match the creation time of
+ // the change.
+ boolean excludeAllCreatedOn = false;
+ if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
+ excludeAllCreatedOn = !createdOnIsMonotonic(as, ids) && createdOnIsMonotonic(bs, ids);
+ } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
+ excludeAllCreatedOn = createdOnIsMonotonic(as, ids) && !createdOnIsMonotonic(bs, ids);
+ }
+
+ for (PatchSet.Id id : ids) {
+ PatchSet a = as.get(id);
+ PatchSet b = bs.get(id);
+ String desc = describe(id);
+ String pushCertField = "pushCertificate";
+
+ boolean excludeCreatedOn = excludeAllCreatedOn;
+ boolean excludeDesc = false;
+ if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
+ excludeDesc = Objects.equals(trimOrNull(a.getDescription()), b.getDescription());
+ excludeCreatedOn |=
+ Optional.of(id).equals(minB) && b.getCreatedOn().equals(bundleB.change.getCreatedOn());
+ } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
+ excludeDesc = Objects.equals(a.getDescription(), trimOrNull(b.getDescription()));
+ excludeCreatedOn |=
+ Optional.of(id).equals(minA) && a.getCreatedOn().equals(bundleA.change.getCreatedOn());
+ }
+
+ List<String> exclude = Lists.newArrayList(pushCertField);
+ if (excludeCreatedOn) {
+ exclude.add("createdOn");
+ }
+ if (excludeDesc) {
+ exclude.add("description");
+ }
+
+ diffColumnsExcluding(diffs, PatchSet.class, desc, bundleA, a, bundleB, b, exclude);
+ diffValues(diffs, desc, trimPushCert(a), trimPushCert(b), pushCertField);
+ }
+ }
+
+ private static String trimPushCert(PatchSet ps) {
+ if (ps.getPushCertificate() == null) {
+ return null;
+ }
+ return CharMatcher.is('\n').trimTrailingFrom(ps.getPushCertificate());
+ }
+
+ private static boolean createdOnIsMonotonic(
+ Map<?, PatchSet> patchSets, Set<PatchSet.Id> limitToIds) {
+ List<PatchSet> orderedById =
+ patchSets.values().stream()
+ .filter(ps -> limitToIds.contains(ps.getId()))
+ .sorted(ChangeUtil.PS_ID_ORDER)
+ .collect(toList());
+ return Ordering.natural().onResultOf(PatchSet::getCreatedOn).isOrdered(orderedById);
+ }
+
+ private static void diffPatchSetApprovals(
+ List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
+ Map<PatchSetApproval.Key, PatchSetApproval> as = bundleA.filterPatchSetApprovals();
+ Map<PatchSetApproval.Key, PatchSetApproval> bs = bundleB.filterPatchSetApprovals();
+ for (PatchSetApproval.Key k : diffKeySets(diffs, as, bs)) {
+ PatchSetApproval a = as.get(k);
+ PatchSetApproval b = bs.get(k);
+ String desc = describe(k);
+
+ // ReviewDb allows timestamps before patch set was created, but NoteDb
+ // truncates this to the patch set creation timestamp.
+ //
+ // ChangeRebuilder ensures all post-submit approvals happen after the
+ // actual submit, so the timestamps may not line up. This shouldn't really
+ // happen, because postSubmit shouldn't be set in ReviewDb until after the
+ // change is submitted in ReviewDb, but you never know.
+ //
+ // Due to a quirk of PostReview, post-submit 0 votes might not have the
+ // postSubmit bit set in ReviewDb. As these are only used for tombstone
+ // purposes, ignore the postSubmit bit in NoteDb in this case.
+ Timestamp ta = a.getGranted();
+ Timestamp tb = b.getGranted();
+ PatchSet psa = requireNonNull(bundleA.patchSets.get(a.getPatchSetId()));
+ PatchSet psb = requireNonNull(bundleB.patchSets.get(b.getPatchSetId()));
+ boolean excludeGranted = false;
+ boolean excludePostSubmit = false;
+ List<String> exclude = new ArrayList<>(1);
+ if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
+ excludeGranted =
+ (ta.before(psa.getCreatedOn()) && tb.equals(psb.getCreatedOn()))
+ || ta.compareTo(tb) < 0;
+ excludePostSubmit = a.getValue() == 0 && b.isPostSubmit();
+ } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
+ excludeGranted =
+ (tb.before(psb.getCreatedOn()) && ta.equals(psa.getCreatedOn()))
+ || (tb.compareTo(ta) < 0);
+ excludePostSubmit = b.getValue() == 0 && a.isPostSubmit();
+ }
+
+ // Legacy submit approvals may or may not have tags associated with them,
+ // depending on whether ChangeRebuilder happened to group them with the
+ // status change.
+ boolean excludeTag =
+ bundleA.source != bundleB.source && a.isLegacySubmit() && b.isLegacySubmit();
+
+ if (excludeGranted) {
+ exclude.add("granted");
+ }
+ if (excludePostSubmit) {
+ exclude.add("postSubmit");
+ }
+ if (excludeTag) {
+ exclude.add("tag");
+ }
+
+ diffColumnsExcluding(diffs, PatchSetApproval.class, desc, bundleA, a, bundleB, b, exclude);
+ }
+ }
+
+ private static void diffReviewers(
+ List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
+ diffSets(diffs, bundleA.reviewers.all(), bundleB.reviewers.all(), "reviewer");
+ }
+
+ private static void diffPatchLineComments(
+ List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
+ Map<PatchLineComment.Key, PatchLineComment> as = bundleA.filterPatchLineComments();
+ Map<PatchLineComment.Key, PatchLineComment> bs = bundleB.filterPatchLineComments();
+ for (PatchLineComment.Key k : diffKeySets(diffs, as, bs)) {
+ PatchLineComment a = as.get(k);
+ PatchLineComment b = bs.get(k);
+ String desc = describe(k);
+ diffColumns(diffs, PatchLineComment.class, desc, bundleA, a, bundleB, b);
+ }
+ }
+
+ private static <T> Set<T> diffKeySets(List<String> diffs, Map<T, ?> a, Map<T, ?> b) {
+ if (a.isEmpty() && b.isEmpty()) {
+ return a.keySet();
+ }
+ String clazz = keyClass((!a.isEmpty() ? a.keySet() : b.keySet()).iterator().next());
+ return diffSets(diffs, a.keySet(), b.keySet(), clazz);
+ }
+
+ private static <T> Set<T> diffSets(List<String> diffs, Set<T> as, Set<T> bs, String desc) {
+ if (as.isEmpty() && bs.isEmpty()) {
+ return as;
+ }
+
+ Set<T> aNotB = Sets.difference(as, bs);
+ Set<T> bNotA = Sets.difference(bs, as);
+ if (aNotB.isEmpty() && bNotA.isEmpty()) {
+ return as;
+ }
+ diffs.add(desc + " sets differ: " + aNotB + " only in A; " + bNotA + " only in B");
+ return Sets.intersection(as, bs);
+ }
+
+ private static <T> void diffColumns(
+ List<String> diffs,
+ Class<T> clazz,
+ String desc,
+ ChangeBundle bundleA,
+ T a,
+ ChangeBundle bundleB,
+ T b) {
+ diffColumnsExcluding(diffs, clazz, desc, bundleA, a, bundleB, b);
+ }
+
+ private static <T> void diffColumnsExcluding(
+ List<String> diffs,
+ Class<T> clazz,
+ String desc,
+ ChangeBundle bundleA,
+ T a,
+ ChangeBundle bundleB,
+ T b,
+ String... exclude) {
+ diffColumnsExcluding(diffs, clazz, desc, bundleA, a, bundleB, b, Arrays.asList(exclude));
+ }
+
+ private static <T> void diffColumnsExcluding(
+ List<String> diffs,
+ Class<T> clazz,
+ String desc,
+ ChangeBundle bundleA,
+ T a,
+ ChangeBundle bundleB,
+ T b,
+ Iterable<String> exclude) {
+ Set<String> toExclude = Sets.newLinkedHashSet(exclude);
+ for (Field f : clazz.getDeclaredFields()) {
+ Column col = f.getAnnotation(Column.class);
+ if (col == null) {
+ continue;
+ } else if (toExclude.remove(f.getName())) {
+ continue;
+ }
+ f.setAccessible(true);
+ try {
+ if (Timestamp.class.isAssignableFrom(f.getType())) {
+ diffTimestamps(diffs, desc, bundleA, a, bundleB, b, f.getName());
+ } else {
+ diffValues(diffs, desc, f.get(a), f.get(b), f.getName());
+ }
+ } catch (IllegalAccessException e) {
+ throw new IllegalArgumentException(e);
+ }
+ }
+ checkArgument(
+ toExclude.isEmpty(),
+ "requested columns to exclude not present in %s: %s",
+ clazz.getSimpleName(),
+ toExclude);
+ }
+
+ private static void diffTimestamps(
+ List<String> diffs,
+ String desc,
+ ChangeBundle bundleA,
+ Object a,
+ ChangeBundle bundleB,
+ Object b,
+ String field) {
+ checkArgument(a.getClass() == b.getClass());
+ Class<?> clazz = a.getClass();
+
+ Timestamp ta;
+ Timestamp tb;
+ try {
+ Field f = clazz.getDeclaredField(field);
+ checkArgument(f.getAnnotation(Column.class) != null);
+ f.setAccessible(true);
+ ta = (Timestamp) f.get(a);
+ tb = (Timestamp) f.get(b);
+ } catch (IllegalAccessException | NoSuchFieldException | SecurityException e) {
+ throw new IllegalArgumentException(e);
+ }
+ diffTimestamps(diffs, desc, bundleA, ta, bundleB, tb, field);
+ }
+
+ private static void diffTimestamps(
+ List<String> diffs,
+ String desc,
+ ChangeBundle bundleA,
+ Timestamp ta,
+ ChangeBundle bundleB,
+ Timestamp tb,
+ String fieldDesc) {
+ if (bundleA.source == bundleB.source || ta == null || tb == null) {
+ diffValues(diffs, desc, ta, tb, fieldDesc);
+ } else if (bundleA.source == NOTE_DB) {
+ diffTimestamps(diffs, desc, bundleA.getChange(), ta, bundleB.getChange(), tb, fieldDesc);
+ } else {
+ diffTimestamps(diffs, desc, bundleB.getChange(), tb, bundleA.getChange(), ta, fieldDesc);
+ }
+ }
+
+ private static boolean timestampsDiffer(
+ ChangeBundle bundleA, Timestamp ta, ChangeBundle bundleB, Timestamp tb) {
+ List<String> tempDiffs = new ArrayList<>(1);
+ diffTimestamps(tempDiffs, "temp", bundleA, ta, bundleB, tb, "temp");
+ return !tempDiffs.isEmpty();
+ }
+
+ private static void diffTimestamps(
+ List<String> diffs,
+ String desc,
+ Change changeFromNoteDb,
+ Timestamp tsFromNoteDb,
+ Change changeFromReviewDb,
+ Timestamp tsFromReviewDb,
+ String field) {
+ // Because ChangeRebuilder may batch events together that are several
+ // seconds apart, the timestamp in NoteDb may actually be several seconds
+ // *earlier* than the timestamp in ReviewDb that it was converted from.
+ checkArgument(
+ tsFromNoteDb.equals(truncateToSecond(tsFromNoteDb)),
+ "%s from NoteDb has non-rounded %s timestamp: %s",
+ desc,
+ field,
+ tsFromNoteDb);
+
+ if (tsFromReviewDb.before(changeFromReviewDb.getCreatedOn())
+ && tsFromNoteDb.equals(changeFromNoteDb.getCreatedOn())) {
+ // Timestamp predates change creation. These are truncated to change
+ // creation time during NoteDb conversion, so allow this if the timestamp
+ // in NoteDb matches the createdOn time in NoteDb.
+ return;
+ }
+
+ long delta = tsFromReviewDb.getTime() - tsFromNoteDb.getTime();
+ long max = ChangeRebuilderImpl.MAX_WINDOW_MS;
+ if (delta < 0 || delta > max) {
+ diffs.add(
+ field
+ + " differs for "
+ + desc
+ + " in NoteDb vs. ReviewDb:"
+ + " {"
+ + tsFromNoteDb
+ + "} != {"
+ + tsFromReviewDb
+ + "}");
+ }
+ }
+
+ private static void diffValues(
+ List<String> diffs, String desc, Object va, Object vb, String name) {
+ if (!Objects.equals(va, vb)) {
+ diffs.add(name + " differs for " + desc + ": {" + va + "} != {" + vb + "}");
+ }
+ }
+
+ private static String describe(Object key) {
+ return keyClass(key) + " " + key;
+ }
+
+ private static String keyClass(Object obj) {
+ Class<?> clazz = obj.getClass();
+ String name = clazz.getSimpleName();
+ checkArgument(name.endsWith("Key") || name.endsWith("Id"), "not an Id/Key class: %s", name);
+ if (name.equals("Key") || name.equals("Id")) {
+ return clazz.getEnclosingClass().getSimpleName() + "." + name;
+ } else if (name.startsWith("AutoValue_")) {
+ return name.substring(name.lastIndexOf('_') + 1);
+ }
+ return name;
+ }
+
+ @Override
+ public String toString() {
+ return getClass().getSimpleName()
+ + "{id="
+ + change.getId()
+ + ", ChangeMessage["
+ + changeMessages.size()
+ + "]"
+ + ", PatchSet["
+ + patchSets.size()
+ + "]"
+ + ", PatchSetApproval["
+ + patchSetApprovals.size()
+ + "]"
+ + ", PatchLineComment["
+ + patchLineComments.size()
+ + "]"
+ + "}";
+ }
+}