diff options
Diffstat (limited to 'java/com/google/gerrit/server/notedb/ChangeBundle.java')
-rw-r--r-- | java/com/google/gerrit/server/notedb/ChangeBundle.java | 973 |
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() + + "]" + + "}"; + } +} |