diff options
Diffstat (limited to 'javatests/com/google/gerrit/server/notedb/ChangeBundleTest.java')
-rw-r--r-- | javatests/com/google/gerrit/server/notedb/ChangeBundleTest.java | 1976 |
1 files changed, 1976 insertions, 0 deletions
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeBundleTest.java b/javatests/com/google/gerrit/server/notedb/ChangeBundleTest.java new file mode 100644 index 0000000000..fc2a272082 --- /dev/null +++ b/javatests/com/google/gerrit/server/notedb/ChangeBundleTest.java @@ -0,0 +1,1976 @@ +// 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.Preconditions.checkArgument; +import static com.google.common.truth.Truth.assertThat; +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.notedb.ReviewerStateInternal.CC; +import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER; +import static com.google.gerrit.server.util.time.TimeUtil.truncateToSecond; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; + +import com.google.common.collect.HashBasedTable; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Table; +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.LabelId; +import com.google.gerrit.reviewdb.client.Patch; +import com.google.gerrit.reviewdb.client.PatchLineComment; +import com.google.gerrit.reviewdb.client.PatchSet; +import com.google.gerrit.reviewdb.client.PatchSetApproval; +import com.google.gerrit.reviewdb.client.Project; +import com.google.gerrit.reviewdb.client.RevId; +import com.google.gerrit.server.ReviewerSet; +import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl; +import com.google.gerrit.server.util.time.TimeUtil; +import com.google.gerrit.testing.GerritBaseTests; +import com.google.gerrit.testing.TestChanges; +import com.google.gerrit.testing.TestTimeUtil; +import com.google.gwtorm.protobuf.CodecFactory; +import com.google.gwtorm.protobuf.ProtobufCodec; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.Month; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.TimeZone; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class ChangeBundleTest extends GerritBaseTests { + private static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class); + private static final ProtobufCodec<ChangeMessage> CHANGE_MESSAGE_CODEC = + CodecFactory.encoder(ChangeMessage.class); + private static final ProtobufCodec<PatchSet> PATCH_SET_CODEC = + CodecFactory.encoder(PatchSet.class); + private static final ProtobufCodec<PatchSetApproval> PATCH_SET_APPROVAL_CODEC = + CodecFactory.encoder(PatchSetApproval.class); + private static final ProtobufCodec<PatchLineComment> PATCH_LINE_COMMENT_CODEC = + CodecFactory.encoder(PatchLineComment.class); + private static final String TIMEZONE_ID = "US/Eastern"; + + private String systemTimeZoneProperty; + private TimeZone systemTimeZone; + + private Project.NameKey project; + private Account.Id accountId; + + @Before + public void setUp() { + systemTimeZoneProperty = System.setProperty("user.timezone", TIMEZONE_ID); + systemTimeZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone(TIMEZONE_ID)); + long maxMs = ChangeRebuilderImpl.MAX_WINDOW_MS; + assertThat(maxMs).isGreaterThan(1000L); + TestTimeUtil.resetWithClockStep(maxMs * 2, MILLISECONDS); + project = new Project.NameKey("project"); + accountId = new Account.Id(100); + } + + @After + public void tearDown() { + TestTimeUtil.useSystemTime(); + System.setProperty("user.timezone", systemTimeZoneProperty); + TimeZone.setDefault(systemTimeZone); + } + + private void superWindowResolution() { + TestTimeUtil.setClockStep(ChangeRebuilderImpl.MAX_WINDOW_MS * 2, MILLISECONDS); + TimeUtil.nowTs(); + } + + private void subWindowResolution() { + TestTimeUtil.setClockStep(1, SECONDS); + TimeUtil.nowTs(); + } + + @Test + public void diffChangesDifferentIds() throws Exception { + Change c1 = TestChanges.newChange(project, accountId); + int id1 = c1.getId().get(); + Change c2 = TestChanges.newChange(project, accountId); + int id2 = c2.getId().get(); + ChangeBundle b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + + assertDiffs( + b1, + b2, + "changeId differs for Changes: {" + id1 + "} != {" + id2 + "}", + "createdOn differs for Changes: {2009-09-30 17:00:00.0} != {2009-09-30 17:00:06.0}", + "effective last updated time differs for Changes:" + + " {2009-09-30 17:00:00.0} != {2009-09-30 17:00:06.0}"); + } + + @Test + public void diffChangesSameId() throws Exception { + Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100)); + Change c2 = clone(c1); + ChangeBundle b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + + assertNoDiffs(b1, b2); + + c2.setTopic("topic"); + assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {null} != {topic}"); + } + + @Test + public void diffChangesMixedSourcesAllowsSlop() throws Exception { + subWindowResolution(); + Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100)); + Change c2 = clone(c1); + c2.setCreatedOn(TimeUtil.nowTs()); + c2.setLastUpdatedOn(TimeUtil.nowTs()); + + // Both are ReviewDb, exact timestamp match is required. + ChangeBundle b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, + b2, + "createdOn differs for Change.Id " + + c1.getId() + + ":" + + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:02.0}", + "effective last updated time differs for Change.Id " + + c1.getId() + + ":" + + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:03.0}"); + + // One NoteDb, slop is allowed. + b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + assertNoDiffs(b1, b2); + assertNoDiffs(b2, b1); + + // But not too much slop. + superWindowResolution(); + Change c3 = clone(c1); + c3.setLastUpdatedOn(TimeUtil.nowTs()); + b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + ChangeBundle b3 = + new ChangeBundle( + c3, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + String msg = + "effective last updated time differs for Change.Id " + + c1.getId() + + " in NoteDb vs. ReviewDb:" + + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:10.0}"; + assertDiffs(b1, b3, msg); + assertDiffs(b3, b1, msg); + } + + @Test + public void diffChangesIgnoresOriginalSubjectInReviewDb() throws Exception { + Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100)); + c1.setCurrentPatchSet(c1.currentPatchSetId(), "Subject", "Original A"); + Change c2 = clone(c1); + c2.setCurrentPatchSet(c2.currentPatchSetId(), c1.getSubject(), "Original B"); + + // Both ReviewDb, exact match required. + ChangeBundle b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, + b2, + "originalSubject differs for Change.Id " + + c1.getId() + + ":" + + " {Original A} != {Original B}"); + + // Both NoteDb, exact match required. + b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + assertDiffs( + b1, + b2, + "originalSubject differs for Change.Id " + + c1.getId() + + ":" + + " {Original A} != {Original B}"); + + // One ReviewDb, one NoteDb, original subject is ignored. + b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + assertNoDiffs(b1, b2); + assertNoDiffs(b2, b1); + } + + @Test + public void diffChangesSanitizesSubjectsBeforeComparison() throws Exception { + Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100)); + c1.setCurrentPatchSet(c1.currentPatchSetId(), "Subject\r\rbody", "Original"); + Change c2 = clone(c1); + c2.setCurrentPatchSet(c2.currentPatchSetId(), "Subject body", "Original"); + + // Both ReviewDb, exact match required + ChangeBundle b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, + b2, + "subject differs for Change.Id " + + c1.getId() + + ":" + + " {Subject\r\rbody} != {Subject body}"); + + // Both NoteDb, exact match required (although it should be impossible to + // create a NoteDb change with '\r' in the subject). + b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + assertDiffs( + b1, + b2, + "subject differs for Change.Id " + + c1.getId() + + ":" + + " {Subject\r\rbody} != {Subject body}"); + + // One ReviewDb, one NoteDb, '\r' is normalized to ' '. + b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + assertNoDiffs(b1, b2); + assertNoDiffs(b2, b1); + } + + @Test + public void diffChangesConsidersEmptyReviewDbTopicEquivalentToNullInNoteDb() throws Exception { + Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100)); + c1.setTopic(""); + Change c2 = clone(c1); + c2.setTopic(null); + + // Both ReviewDb, exact match required. + ChangeBundle b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {} != {null}"); + + // Topic ignored if ReviewDb is empty and NoteDb is null. + b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + assertNoDiffs(b1, b2); + + // Exact match still required if NoteDb has empty value (not realistic). + b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {} != {null}"); + + // Null is not equal to a non-empty string. + Change c3 = clone(c1); + c3.setTopic("topic"); + b1 = + new ChangeBundle( + c3, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {topic} != {null}"); + + // Null is equal to a string that is all whitespace. + Change c4 = clone(c1); + c4.setTopic(" "); + b1 = + new ChangeBundle( + c4, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + assertNoDiffs(b1, b2); + assertNoDiffs(b2, b1); + } + + @Test + public void diffChangesIgnoresLeadingAndTrailingWhitespaceInReviewDbTopics() throws Exception { + Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100)); + c1.setTopic(" abc "); + Change c2 = clone(c1); + c2.setTopic("abc"); + + // Both ReviewDb, exact match required. + ChangeBundle b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": { abc } != {abc}"); + + // Leading whitespace in ReviewDb topic is ignored. + b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + assertNoDiffs(b1, b2); + assertNoDiffs(b2, b1); + + // Must match except for the leading/trailing whitespace. + Change c3 = clone(c1); + c3.setTopic("cba"); + b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle( + c3, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": { abc } != {cba}"); + } + + @Test + public void diffChangesTakesMaxEntityTimestampFromReviewDb() throws Exception { + Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100)); + PatchSet ps = new PatchSet(c1.currentPatchSetId()); + ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")); + ps.setUploader(accountId); + ps.setCreatedOn(TimeUtil.nowTs()); + PatchSetApproval a = + new PatchSetApproval( + new PatchSetApproval.Key(c1.currentPatchSetId(), accountId, new LabelId("Code-Review")), + (short) 1, + TimeUtil.nowTs()); + + Change c2 = clone(c1); + c2.setLastUpdatedOn(a.getGranted()); + + // Both ReviewDb, exact match required. + ChangeBundle b1 = + new ChangeBundle( + c1, messages(), patchSets(ps), approvals(a), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c2, messages(), patchSets(ps), approvals(a), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, + b2, + "effective last updated time differs for Change.Id " + + c1.getId() + + ":" + + " {2009-09-30 17:00:00.0} != {2009-09-30 17:00:12.0}"); + + // NoteDb allows latest timestamp from all entities in bundle. + b2 = + new ChangeBundle( + c2, messages(), patchSets(ps), approvals(a), comments(), reviewers(), NOTE_DB); + assertNoDiffs(b1, b2); + } + + @Test + public void diffChangesIgnoresChangeTimestampIfAnyOtherEntitiesExist() { + Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100)); + PatchSet ps = new PatchSet(c1.currentPatchSetId()); + ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")); + ps.setUploader(accountId); + ps.setCreatedOn(TimeUtil.nowTs()); + PatchSetApproval a = + new PatchSetApproval( + new PatchSetApproval.Key(c1.currentPatchSetId(), accountId, new LabelId("Code-Review")), + (short) 1, + TimeUtil.nowTs()); + c1.setLastUpdatedOn(a.getGranted()); + + Change c2 = clone(c1); + c2.setLastUpdatedOn(TimeUtil.nowTs()); + + // ReviewDb has later lastUpdatedOn timestamp than NoteDb, allowed since + // NoteDb matches the latest timestamp of a non-Change entity. + ChangeBundle b1 = + new ChangeBundle( + c2, messages(), patchSets(ps), approvals(a), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c1, messages(), patchSets(ps), approvals(a), comments(), reviewers(), NOTE_DB); + assertThat(b1.getChange().getLastUpdatedOn()).isGreaterThan(b2.getChange().getLastUpdatedOn()); + assertNoDiffs(b1, b2); + + // Timestamps must actually match if Change is the only entity. + b1 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + assertDiffs( + b1, + b2, + "effective last updated time differs for Change.Id " + + c1.getId() + + " in NoteDb vs. ReviewDb:" + + " {2009-09-30 17:00:12.0} != {2009-09-30 17:00:18.0}"); + } + + @Test + public void diffChangesAllowsReviewDbSubjectToBePrefixOfNoteDbSubject() throws Exception { + Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100)); + Change c2 = clone(c1); + c2.setCurrentPatchSet( + c1.currentPatchSetId(), c1.getSubject().substring(0, 10), c1.getOriginalSubject()); + assertThat(c2.getSubject()).isNotEqualTo(c1.getSubject()); + + // Both ReviewDb, exact match required. + ChangeBundle b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, + b2, + "subject differs for Change.Id " + c1.getId() + ": {Change subject} != {Change sub}"); + + // ReviewDb has shorter subject, allowed. + b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + assertNoDiffs(b1, b2); + + // NoteDb has shorter subject, not allowed. + b1 = + new ChangeBundle( + c1, messages(), latest(c1), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle(c2, messages(), latest(c2), approvals(), comments(), reviewers(), NOTE_DB); + assertDiffs( + b1, + b2, + "subject differs for Change.Id " + c1.getId() + ": {Change subject} != {Change sub}"); + } + + @Test + public void diffChangesTrimsLeadingSpacesFromReviewDbComparingToNoteDb() throws Exception { + Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100)); + Change c2 = clone(c1); + c2.setCurrentPatchSet(c1.currentPatchSetId(), " " + c1.getSubject(), c1.getOriginalSubject()); + + // Both ReviewDb, exact match required. + ChangeBundle b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, + b2, + "subject differs for Change.Id " + + c1.getId() + + ":" + + " {Change subject} != { Change subject}"); + + // ReviewDb is missing leading spaces, allowed. + b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + assertNoDiffs(b1, b2); + assertNoDiffs(b2, b1); + } + + @Test + public void diffChangesDoesntTrimLeadingNonSpaceWhitespaceFromSubject() throws Exception { + Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100)); + Change c2 = clone(c1); + c2.setCurrentPatchSet(c1.currentPatchSetId(), "\t" + c1.getSubject(), c1.getOriginalSubject()); + + // Both ReviewDb. + ChangeBundle b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, + b2, + "subject differs for Change.Id " + + c1.getId() + + ":" + + " {Change subject} != {\tChange subject}"); + + // One NoteDb. + b1 = + new ChangeBundle(c1, messages(), latest(c1), approvals(), comments(), reviewers(), NOTE_DB); + b2 = + new ChangeBundle( + c2, messages(), latest(c2), approvals(), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, + b2, + "subject differs for Change.Id " + + c1.getId() + + ":" + + " {Change subject} != {\tChange subject}"); + assertDiffs( + b2, + b1, + "subject differs for Change.Id " + + c1.getId() + + ":" + + " {\tChange subject} != {Change subject}"); + } + + @Test + public void diffChangesHandlesBuggyJGitSubjectExtraction() throws Exception { + Change c1 = TestChanges.newChange(project, accountId); + String buggySubject = "Subject\r \r Rest of message."; + c1.setCurrentPatchSet(c1.currentPatchSetId(), buggySubject, buggySubject); + Change c2 = clone(c1); + c2.setCurrentPatchSet(c2.currentPatchSetId(), "Subject", "Subject"); + + // Both ReviewDb. + ChangeBundle b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, + b2, + "originalSubject differs for Change.Id " + + c1.getId() + + ":" + + " {Subject\r \r Rest of message.} != {Subject}", + "subject differs for Change.Id " + + c1.getId() + + ":" + + " {Subject\r \r Rest of message.} != {Subject}"); + + // NoteDb has correct subject without "\r ". + b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + assertNoDiffs(b1, b2); + assertNoDiffs(b2, b1); + } + + @Test + public void diffChangesIgnoresInvalidCurrentPatchSetIdInReviewDb() throws Exception { + Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100)); + Change c2 = clone(c1); + c2.setCurrentPatchSet( + new PatchSet.Id(c2.getId(), 0), "Unrelated subject", c2.getOriginalSubject()); + + // Both ReviewDb. + ChangeBundle b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, + b2, + "currentPatchSetId differs for Change.Id " + c1.getId() + ": {1} != {0}", + "subject differs for Change.Id " + + c1.getId() + + ":" + + " {Change subject} != {Unrelated subject}"); + + // One NoteDb. + // + // This is based on a real corrupt change where all patch sets were deleted + // but the Change entity stuck around, resulting in a currentPatchSetId of 0 + // after converting to NoteDb. + b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + assertNoDiffs(b1, b2); + assertNoDiffs(b2, b1); + } + + @Test + public void diffChangesAllowsCreatedToMatchLastUpdated() throws Exception { + Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100)); + c1.setCreatedOn(TimeUtil.nowTs()); + assertThat(c1.getCreatedOn()).isGreaterThan(c1.getLastUpdatedOn()); + Change c2 = clone(c1); + c2.setCreatedOn(c2.getLastUpdatedOn()); + + // Both ReviewDb. + ChangeBundle b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, + b2, + "createdOn differs for Change.Id " + + c1.getId() + + ": {2009-09-30 17:00:06.0} != {2009-09-30 17:00:00.0}"); + + // One NoteDb. + b1 = + new ChangeBundle( + c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle( + c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB); + assertNoDiffs(b1, b2); + assertNoDiffs(b2, b1); + } + + @Test + public void diffChangeMessageKeySets() throws Exception { + Change c = TestChanges.newChange(project, accountId); + int id = c.getId().get(); + ChangeMessage cm1 = + new ChangeMessage( + new ChangeMessage.Key(c.getId(), "uuid1"), + accountId, + TimeUtil.nowTs(), + c.currentPatchSetId()); + ChangeMessage cm2 = + new ChangeMessage( + new ChangeMessage.Key(c.getId(), "uuid2"), + accountId, + TimeUtil.nowTs(), + c.currentPatchSetId()); + ChangeBundle b1 = + new ChangeBundle( + c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + + assertDiffs( + b1, + b2, + "ChangeMessage.Key sets differ:" + + " [" + + id + + ",uuid1] only in A; [" + + id + + ",uuid2] only in B"); + } + + @Test + public void diffChangeMessages() throws Exception { + Change c = TestChanges.newChange(project, accountId); + ChangeMessage cm1 = + new ChangeMessage( + new ChangeMessage.Key(c.getId(), "uuid"), + accountId, + TimeUtil.nowTs(), + c.currentPatchSetId()); + cm1.setMessage("message 1"); + ChangeMessage cm2 = clone(cm1); + ChangeBundle b1 = + new ChangeBundle( + c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + + assertNoDiffs(b1, b2); + + cm2.setMessage("message 2"); + assertDiffs( + b1, + b2, + "message differs for ChangeMessage.Key " + + c.getId() + + ",uuid:" + + " {message 1} != {message 2}"); + } + + @Test + public void diffChangeMessagesIgnoresUuids() throws Exception { + Change c = TestChanges.newChange(project, accountId); + int id = c.getId().get(); + ChangeMessage cm1 = + new ChangeMessage( + new ChangeMessage.Key(c.getId(), "uuid1"), + accountId, + TimeUtil.nowTs(), + c.currentPatchSetId()); + cm1.setMessage("message 1"); + ChangeMessage cm2 = clone(cm1); + cm2.getKey().set("uuid2"); + + ChangeBundle b1 = + new ChangeBundle( + c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + // Both are ReviewDb, exact UUID match is required. + assertDiffs( + b1, + b2, + "ChangeMessage.Key sets differ:" + + " [" + + id + + ",uuid1] only in A; [" + + id + + ",uuid2] only in B"); + + // One NoteDb, UUIDs are ignored. + b1 = + new ChangeBundle( + c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle( + c, messages(cm2), latest(c), approvals(), comments(), reviewers(), NOTE_DB); + assertNoDiffs(b1, b2); + } + + @Test + public void diffChangeMessagesWithDifferentCounts() throws Exception { + Change c = TestChanges.newChange(project, accountId); + int id = c.getId().get(); + ChangeMessage cm1 = + new ChangeMessage( + new ChangeMessage.Key(c.getId(), "uuid1"), + accountId, + TimeUtil.nowTs(), + c.currentPatchSetId()); + cm1.setMessage("message 1"); + ChangeMessage cm2 = + new ChangeMessage( + new ChangeMessage.Key(c.getId(), "uuid2"), + accountId, + TimeUtil.nowTs(), + c.currentPatchSetId()); + cm1.setMessage("message 2"); + + // Both ReviewDb: Uses same keySet diff as other types. + ChangeBundle b1 = + new ChangeBundle( + c, messages(cm1, cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, b2, "ChangeMessage.Key sets differ: [" + id + ",uuid2] only in A; [] only in B"); + + // One NoteDb: UUIDs in keys can't be used for comparison, just diff counts. + b1 = + new ChangeBundle( + c, messages(cm1, cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle( + c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB); + assertDiffs(b1, b2, "ChangeMessages differ for Change.Id " + id + "\nOnly in A:\n " + cm2); + assertDiffs(b2, b1, "ChangeMessages differ for Change.Id " + id + "\nOnly in B:\n " + cm2); + } + + @Test + public void diffChangeMessagesMixedSourcesWithDifferences() throws Exception { + Change c = TestChanges.newChange(project, accountId); + int id = c.getId().get(); + ChangeMessage cm1 = + new ChangeMessage( + new ChangeMessage.Key(c.getId(), "uuid1"), + accountId, + TimeUtil.nowTs(), + c.currentPatchSetId()); + cm1.setMessage("message 1"); + ChangeMessage cm2 = clone(cm1); + cm2.setMessage("message 2"); + ChangeMessage cm3 = clone(cm1); + cm3.getKey().set("uuid2"); // Differs only in UUID. + + ChangeBundle b1 = + new ChangeBundle( + c, messages(cm1, cm3), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(cm2, cm3), latest(c), approvals(), comments(), reviewers(), NOTE_DB); + // Implementation happens to pair up cm1 in b1 with cm3 in b2 because it + // depends on iteration order and doesn't care about UUIDs. The important + // thing is that there's some diff. + assertDiffs( + b1, + b2, + "ChangeMessages differ for Change.Id " + + id + + "\n" + + "Only in A:\n " + + cm3 + + "\n" + + "Only in B:\n " + + cm2); + assertDiffs( + b2, + b1, + "ChangeMessages differ for Change.Id " + + id + + "\n" + + "Only in A:\n " + + cm2 + + "\n" + + "Only in B:\n " + + cm3); + } + + @Test + public void diffChangeMessagesMixedSourcesAllowsSlop() throws Exception { + subWindowResolution(); + Change c = TestChanges.newChange(project, accountId); + ChangeMessage cm1 = + new ChangeMessage( + new ChangeMessage.Key(c.getId(), "uuid1"), + accountId, + TimeUtil.nowTs(), + c.currentPatchSetId()); + ChangeMessage cm2 = clone(cm1); + cm2.setWrittenOn(TimeUtil.nowTs()); + + // Both are ReviewDb, exact timestamp match is required. + ChangeBundle b1 = + new ChangeBundle( + c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, + b2, + "writtenOn differs for ChangeMessage.Key " + + c.getId() + + ",uuid1:" + + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}"); + + // One NoteDb, slop is allowed. + b1 = + new ChangeBundle( + c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB); + b2 = + new ChangeBundle( + c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + assertNoDiffs(b1, b2); + assertNoDiffs(b2, b1); + + // But not too much slop. + superWindowResolution(); + ChangeMessage cm3 = clone(cm1); + cm3.setWrittenOn(TimeUtil.nowTs()); + b1 = + new ChangeBundle( + c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB); + ChangeBundle b3 = + new ChangeBundle( + c, messages(cm3), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + int id = c.getId().get(); + assertDiffs( + b1, + b3, + "ChangeMessages differ for Change.Id " + + id + + "\n" + + "Only in A:\n " + + cm1 + + "\n" + + "Only in B:\n " + + cm3); + assertDiffs( + b3, + b1, + "ChangeMessages differ for Change.Id " + + id + + "\n" + + "Only in A:\n " + + cm3 + + "\n" + + "Only in B:\n " + + cm1); + } + + @Test + public void diffChangeMessagesAllowsNullPatchSetIdFromReviewDb() throws Exception { + Change c = TestChanges.newChange(project, accountId); + int id = c.getId().get(); + ChangeMessage cm1 = + new ChangeMessage( + new ChangeMessage.Key(c.getId(), "uuid"), + accountId, + TimeUtil.nowTs(), + c.currentPatchSetId()); + cm1.setMessage("message 1"); + ChangeMessage cm2 = clone(cm1); + cm2.setPatchSetId(null); + + ChangeBundle b1 = + new ChangeBundle( + c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + + // Both are ReviewDb, exact patch set ID match is required. + assertDiffs( + b1, + b2, + "patchset differs for ChangeMessage.Key " + + c.getId() + + ",uuid:" + + " {" + + id + + ",1} != {null}"); + + // Null patch set ID on ReviewDb is ignored. + b1 = + new ChangeBundle( + c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB); + b2 = + new ChangeBundle( + c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + assertNoDiffs(b1, b2); + + // Null patch set ID on NoteDb is not ignored (but is not realistic). + b1 = + new ChangeBundle( + c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle( + c, messages(cm2), latest(c), approvals(), comments(), reviewers(), NOTE_DB); + assertDiffs( + b1, + b2, + "ChangeMessages differ for Change.Id " + + id + + "\n" + + "Only in A:\n " + + cm1 + + "\n" + + "Only in B:\n " + + cm2); + assertDiffs( + b2, + b1, + "ChangeMessages differ for Change.Id " + + id + + "\n" + + "Only in A:\n " + + cm2 + + "\n" + + "Only in B:\n " + + cm1); + } + + @Test + public void diffPatchSetIdSets() throws Exception { + Change c = TestChanges.newChange(project, accountId); + TestChanges.incrementPatchSet(c); + + PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1)); + ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")); + ps1.setUploader(accountId); + ps1.setCreatedOn(TimeUtil.nowTs()); + PatchSet ps2 = new PatchSet(new PatchSet.Id(c.getId(), 2)); + ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee")); + ps2.setUploader(accountId); + ps2.setCreatedOn(TimeUtil.nowTs()); + + ChangeBundle b1 = + new ChangeBundle( + c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(), patchSets(ps1, ps2), approvals(), comments(), reviewers(), REVIEW_DB); + + assertDiffs(b1, b2, "PatchSet.Id sets differ: [] only in A; [" + c.getId() + ",1] only in B"); + } + + @Test + public void diffPatchSets() throws Exception { + Change c = TestChanges.newChange(project, accountId); + PatchSet ps1 = new PatchSet(c.currentPatchSetId()); + ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")); + ps1.setUploader(accountId); + ps1.setCreatedOn(TimeUtil.nowTs()); + PatchSet ps2 = clone(ps1); + ChangeBundle b1 = + new ChangeBundle( + c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB); + + assertNoDiffs(b1, b2); + + ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee")); + assertDiffs( + b1, + b2, + "revision differs for PatchSet.Id " + + c.getId() + + ",1:" + + " {RevId{deadbeefdeadbeefdeadbeefdeadbeefdeadbeef}}" + + " != {RevId{badc0feebadc0feebadc0feebadc0feebadc0fee}}"); + } + + @Test + public void diffPatchSetsMixedSourcesAllowsSlop() throws Exception { + subWindowResolution(); + Change c = TestChanges.newChange(project, accountId); + PatchSet ps1 = new PatchSet(c.currentPatchSetId()); + ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")); + ps1.setUploader(accountId); + ps1.setCreatedOn(truncateToSecond(TimeUtil.nowTs())); + PatchSet ps2 = clone(ps1); + ps2.setCreatedOn(TimeUtil.nowTs()); + + // Both are ReviewDb, exact timestamp match is required. + ChangeBundle b1 = + new ChangeBundle( + c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, + b2, + "createdOn differs for PatchSet.Id " + + c.getId() + + ",1:" + + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}"); + + // One NoteDb, slop is allowed. + b1 = + new ChangeBundle( + c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), NOTE_DB); + b2 = + new ChangeBundle( + c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB); + assertNoDiffs(b1, b2); + + // But not too much slop. + superWindowResolution(); + PatchSet ps3 = clone(ps1); + ps3.setCreatedOn(TimeUtil.nowTs()); + b1 = + new ChangeBundle( + c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), NOTE_DB); + ChangeBundle b3 = + new ChangeBundle( + c, messages(), patchSets(ps3), approvals(), comments(), reviewers(), REVIEW_DB); + String msg = + "createdOn differs for PatchSet.Id " + + c.getId() + + ",1 in NoteDb vs. ReviewDb:" + + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:10.0}"; + assertDiffs(b1, b3, msg); + assertDiffs(b3, b1, msg); + } + + @Test + public void diffPatchSetsIgnoresTrailingNewlinesInPushCertificate() throws Exception { + subWindowResolution(); + Change c = TestChanges.newChange(project, accountId); + PatchSet ps1 = new PatchSet(c.currentPatchSetId()); + ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")); + ps1.setUploader(accountId); + ps1.setCreatedOn(truncateToSecond(TimeUtil.nowTs())); + ps1.setPushCertificate("some cert"); + PatchSet ps2 = clone(ps1); + ps2.setPushCertificate(ps2.getPushCertificate() + "\n\n"); + + ChangeBundle b1 = + new ChangeBundle( + c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), NOTE_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB); + assertNoDiffs(b1, b2); + assertNoDiffs(b2, b1); + + b1 = + new ChangeBundle( + c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle( + c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), NOTE_DB); + assertNoDiffs(b1, b2); + assertNoDiffs(b2, b1); + } + + @Test + public void diffPatchSetsGreaterThanCurrent() throws Exception { + Change c = TestChanges.newChange(project, accountId); + + PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1)); + ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")); + ps1.setUploader(accountId); + ps1.setCreatedOn(TimeUtil.nowTs()); + PatchSet ps2 = new PatchSet(new PatchSet.Id(c.getId(), 2)); + ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee")); + ps2.setUploader(accountId); + ps2.setCreatedOn(TimeUtil.nowTs()); + assertThat(ps2.getId().get()).isGreaterThan(c.currentPatchSetId().get()); + + ChangeMessage cm1 = + new ChangeMessage( + new ChangeMessage.Key(c.getId(), "uuid1"), + accountId, + TimeUtil.nowTs(), + c.currentPatchSetId()); + ChangeMessage cm2 = + new ChangeMessage( + new ChangeMessage.Key(c.getId(), "uuid2"), + accountId, + TimeUtil.nowTs(), + c.currentPatchSetId()); + + PatchSetApproval a1 = + new PatchSetApproval( + new PatchSetApproval.Key(ps1.getId(), accountId, new LabelId("Code-Review")), + (short) 1, + TimeUtil.nowTs()); + PatchSetApproval a2 = + new PatchSetApproval( + new PatchSetApproval.Key(ps2.getId(), accountId, new LabelId("Code-Review")), + (short) 1, + TimeUtil.nowTs()); + + // Both ReviewDb. + ChangeBundle b1 = + new ChangeBundle( + c, messages(cm1), patchSets(ps1), approvals(a1), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, + messages(cm1, cm2), + patchSets(ps1, ps2), + approvals(a1, a2), + comments(), + reviewers(), + REVIEW_DB); + assertDiffs( + b1, + b2, + "ChangeMessage.Key sets differ: [] only in A; [" + cm2.getKey() + "] only in B", + "PatchSet.Id sets differ: [] only in A; [" + ps2.getId() + "] only in B", + "PatchSetApproval.Key sets differ: [] only in A; [" + a2.getKey() + "] only in B"); + + // One NoteDb. + b1 = + new ChangeBundle( + c, messages(cm1), patchSets(ps1), approvals(a1), comments(), reviewers(), NOTE_DB); + b2 = + new ChangeBundle( + c, + messages(cm1, cm2), + patchSets(ps1, ps2), + approvals(a1, a2), + comments(), + reviewers(), + REVIEW_DB); + assertDiffs( + b1, + b2, + "ChangeMessages differ for Change.Id " + c.getId() + "\nOnly in B:\n " + cm2, + "PatchSet.Id sets differ: [] only in A; [" + ps2.getId() + "] only in B", + "PatchSetApproval.Key sets differ: [] only in A; [" + a2.getKey() + "] only in B"); + + // Both NoteDb. + b1 = + new ChangeBundle( + c, messages(cm1), patchSets(ps1), approvals(a1), comments(), reviewers(), NOTE_DB); + b2 = + new ChangeBundle( + c, + messages(cm1, cm2), + patchSets(ps1, ps2), + approvals(a1, a2), + comments(), + reviewers(), + NOTE_DB); + assertDiffs( + b1, + b2, + "ChangeMessages differ for Change.Id " + c.getId() + "\nOnly in B:\n " + cm2, + "PatchSet.Id sets differ: [] only in A; [" + ps2.getId() + "] only in B", + "PatchSetApproval.Key sets differ: [] only in A; [" + a2.getKey() + "] only in B"); + } + + @Test + public void diffPatchSetsIgnoresLeadingAndTrailingWhitespaceInReviewDbDescriptions() + throws Exception { + Change c = TestChanges.newChange(project, accountId); + + PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1)); + ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")); + ps1.setUploader(accountId); + ps1.setCreatedOn(TimeUtil.nowTs()); + ps1.setDescription(" abc "); + PatchSet ps2 = clone(ps1); + ps2.setDescription("abc"); + + // Both ReviewDb, exact match required. + ChangeBundle b1 = + new ChangeBundle( + c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, b2, "description differs for PatchSet.Id " + ps1.getId() + ": { abc } != {abc}"); + + // Whitespace in ReviewDb description is ignored. + b1 = + new ChangeBundle( + c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle( + c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), NOTE_DB); + assertNoDiffs(b1, b2); + assertNoDiffs(b2, b1); + + // Must match except for the leading/trailing whitespace. + PatchSet ps3 = clone(ps1); + ps3.setDescription("cba"); + b1 = + new ChangeBundle( + c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle( + c, messages(), patchSets(ps3), approvals(), comments(), reviewers(), NOTE_DB); + assertDiffs( + b1, b2, "description differs for PatchSet.Id " + ps1.getId() + ": { abc } != {cba}"); + } + + @Test + public void diffPatchSetsIgnoresCreatedOnWhenReviewDbIsNonMonotonic() throws Exception { + Change c = TestChanges.newChange(project, accountId); + + Timestamp beforePs1 = TimeUtil.nowTs(); + + PatchSet goodPs1 = new PatchSet(new PatchSet.Id(c.getId(), 1)); + goodPs1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")); + goodPs1.setUploader(accountId); + goodPs1.setCreatedOn(TimeUtil.nowTs()); + PatchSet goodPs2 = new PatchSet(new PatchSet.Id(c.getId(), 2)); + goodPs2.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")); + goodPs2.setUploader(accountId); + goodPs2.setCreatedOn(TimeUtil.nowTs()); + assertThat(goodPs2.getCreatedOn()).isGreaterThan(goodPs1.getCreatedOn()); + + PatchSet badPs2 = clone(goodPs2); + badPs2.setCreatedOn(beforePs1); + assertThat(badPs2.getCreatedOn()).isLessThan(goodPs1.getCreatedOn()); + + // Both ReviewDb, exact match required. + ChangeBundle b1 = + new ChangeBundle( + c, + messages(), + patchSets(goodPs1, goodPs2), + approvals(), + comments(), + reviewers(), + REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, + messages(), + patchSets(goodPs1, badPs2), + approvals(), + comments(), + reviewers(), + REVIEW_DB); + assertDiffs( + b1, + b2, + "createdOn differs for PatchSet.Id " + + badPs2.getId() + + ":" + + " {2009-09-30 17:00:18.0} != {2009-09-30 17:00:06.0}"); + + // Non-monotonic in ReviewDb but monotonic in NoteDb, timestamps are + // ignored, including for ps1. + PatchSet badPs1 = clone(goodPs1); + badPs1.setCreatedOn(TimeUtil.nowTs()); + b1 = + new ChangeBundle( + c, + messages(), + patchSets(badPs1, badPs2), + approvals(), + comments(), + reviewers(), + REVIEW_DB); + b2 = + new ChangeBundle( + c, + messages(), + patchSets(goodPs1, goodPs2), + approvals(), + comments(), + reviewers(), + NOTE_DB); + assertNoDiffs(b1, b2); + assertNoDiffs(b2, b1); + + // Non-monotonic in NoteDb but monotonic in ReviewDb, timestamps are not + // ignored. + b1 = + new ChangeBundle( + c, + messages(), + patchSets(goodPs1, goodPs2), + approvals(), + comments(), + reviewers(), + REVIEW_DB); + b2 = + new ChangeBundle( + c, + messages(), + patchSets(badPs1, badPs2), + approvals(), + comments(), + reviewers(), + NOTE_DB); + assertDiffs( + b1, + b2, + "createdOn differs for PatchSet.Id " + + badPs1.getId() + + " in NoteDb vs. ReviewDb:" + + " {2009-09-30 17:00:24.0} != {2009-09-30 17:00:12.0}", + "createdOn differs for PatchSet.Id " + + badPs2.getId() + + " in NoteDb vs. ReviewDb:" + + " {2009-09-30 17:00:06.0} != {2009-09-30 17:00:18.0}"); + } + + @Test + public void diffPatchSetsAllowsFirstPatchSetCreatedOnToMatchChangeCreatedOn() { + Change c = TestChanges.newChange(project, accountId); + c.setLastUpdatedOn(TimeUtil.nowTs()); + + PatchSet goodPs1 = new PatchSet(new PatchSet.Id(c.getId(), 1)); + goodPs1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")); + goodPs1.setUploader(accountId); + goodPs1.setCreatedOn(TimeUtil.nowTs()); + assertThat(goodPs1.getCreatedOn()).isGreaterThan(c.getCreatedOn()); + + PatchSet ps1AtCreatedOn = clone(goodPs1); + ps1AtCreatedOn.setCreatedOn(c.getCreatedOn()); + + PatchSet goodPs2 = new PatchSet(new PatchSet.Id(c.getId(), 2)); + goodPs2.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")); + goodPs2.setUploader(accountId); + goodPs2.setCreatedOn(TimeUtil.nowTs()); + + PatchSet ps2AtCreatedOn = clone(goodPs2); + ps2AtCreatedOn.setCreatedOn(c.getCreatedOn()); + + // Both ReviewDb, exact match required. + ChangeBundle b1 = + new ChangeBundle( + c, + messages(), + patchSets(goodPs1, goodPs2), + approvals(), + comments(), + reviewers(), + REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, + messages(), + patchSets(ps1AtCreatedOn, ps2AtCreatedOn), + approvals(), + comments(), + reviewers(), + REVIEW_DB); + assertDiffs( + b1, + b2, + "createdOn differs for PatchSet.Id " + + c.getId() + + ",1: {2009-09-30 17:00:12.0} != {2009-09-30 17:00:00.0}", + "createdOn differs for PatchSet.Id " + + c.getId() + + ",2: {2009-09-30 17:00:18.0} != {2009-09-30 17:00:00.0}"); + + // One ReviewDb, PS1 is allowed to match change createdOn, but PS2 isn't. + b1 = + new ChangeBundle( + c, + messages(), + patchSets(goodPs1, goodPs2), + approvals(), + comments(), + reviewers(), + REVIEW_DB); + b2 = + new ChangeBundle( + c, + messages(), + patchSets(ps1AtCreatedOn, ps2AtCreatedOn), + approvals(), + comments(), + reviewers(), + NOTE_DB); + assertDiffs( + b1, + b2, + "createdOn differs for PatchSet.Id " + + c.getId() + + ",2 in NoteDb vs. ReviewDb: {2009-09-30 17:00:00.0} != {2009-09-30 17:00:18.0}"); + assertDiffs( + b2, + b1, + "createdOn differs for PatchSet.Id " + + c.getId() + + ",2 in NoteDb vs. ReviewDb: {2009-09-30 17:00:00.0} != {2009-09-30 17:00:18.0}"); + } + + @Test + public void diffPatchSetApprovalKeySets() throws Exception { + Change c = TestChanges.newChange(project, accountId); + int id = c.getId().get(); + PatchSetApproval a1 = + new PatchSetApproval( + new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")), + (short) 1, + TimeUtil.nowTs()); + PatchSetApproval a2 = + new PatchSetApproval( + new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Verified")), + (short) 1, + TimeUtil.nowTs()); + + ChangeBundle b1 = + new ChangeBundle( + c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB); + + assertDiffs( + b1, + b2, + "PatchSetApproval.Key sets differ:" + + " [" + + id + + "%2C1,100,Code-Review] only in A;" + + " [" + + id + + "%2C1,100,Verified] only in B"); + } + + @Test + public void diffPatchSetApprovals() throws Exception { + Change c = TestChanges.newChange(project, accountId); + PatchSetApproval a1 = + new PatchSetApproval( + new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")), + (short) 1, + TimeUtil.nowTs()); + PatchSetApproval a2 = clone(a1); + ChangeBundle b1 = + new ChangeBundle( + c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB); + + assertNoDiffs(b1, b2); + + a2.setValue((short) -1); + assertDiffs( + b1, + b2, + "value differs for PatchSetApproval.Key " + + c.getId() + + "%2C1,100,Code-Review: {1} != {-1}"); + } + + @Test + public void diffPatchSetApprovalsMixedSourcesAllowsSlop() throws Exception { + Change c = TestChanges.newChange(project, accountId); + subWindowResolution(); + PatchSetApproval a1 = + new PatchSetApproval( + new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")), + (short) 1, + truncateToSecond(TimeUtil.nowTs())); + PatchSetApproval a2 = clone(a1); + a2.setGranted(TimeUtil.nowTs()); + + // Both are ReviewDb, exact timestamp match is required. + ChangeBundle b1 = + new ChangeBundle( + c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, + b2, + "granted differs for PatchSetApproval.Key " + + c.getId() + + "%2C1,100,Code-Review:" + + " {2009-09-30 17:00:07.0} != {2009-09-30 17:00:08.0}"); + + // One NoteDb, slop is allowed. + b1 = + new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(), reviewers(), NOTE_DB); + b2 = + new ChangeBundle( + c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB); + assertNoDiffs(b1, b2); + + // But not too much slop. + superWindowResolution(); + PatchSetApproval a3 = clone(a1); + a3.setGranted(TimeUtil.nowTs()); + b1 = + new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(), reviewers(), NOTE_DB); + ChangeBundle b3 = + new ChangeBundle( + c, messages(), latest(c), approvals(a3), comments(), reviewers(), REVIEW_DB); + String msg = + "granted differs for PatchSetApproval.Key " + + c.getId() + + "%2C1,100,Code-Review in NoteDb vs. ReviewDb:" + + " {2009-09-30 17:00:07.0} != {2009-09-30 17:00:15.0}"; + assertDiffs(b1, b3, msg); + assertDiffs(b3, b1, msg); + } + + @Test + public void diffPatchSetApprovalsAllowsTruncatedTimestampInNoteDb() throws Exception { + Change c = TestChanges.newChange(project, accountId); + PatchSetApproval a1 = + new PatchSetApproval( + new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")), + (short) 1, + c.getCreatedOn()); + PatchSetApproval a2 = clone(a1); + a2.setGranted( + new Timestamp( + LocalDate.of(1900, Month.JANUARY, 1) + .atStartOfDay() + .atZone(ZoneId.of(TIMEZONE_ID)) + .toInstant() + .toEpochMilli())); + + // Both are ReviewDb, exact match is required. + ChangeBundle b1 = + new ChangeBundle( + c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, + b2, + "granted differs for PatchSetApproval.Key " + + c.getId() + + "%2C1,100,Code-Review:" + + " {2009-09-30 17:00:00.0} != {1900-01-01 00:00:00.0}"); + + // Truncating NoteDb timestamp is allowed. + b1 = + new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(), reviewers(), NOTE_DB); + b2 = + new ChangeBundle( + c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB); + assertNoDiffs(b1, b2); + assertNoDiffs(b2, b1); + } + + @Test + public void diffPatchSetApprovalsIgnoresPostSubmitBitOnZeroVote() throws Exception { + Change c = TestChanges.newChange(project, accountId); + c.setStatus(Change.Status.MERGED); + PatchSetApproval a1 = + new PatchSetApproval( + new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")), + (short) 0, + TimeUtil.nowTs()); + a1.setPostSubmit(false); + PatchSetApproval a2 = clone(a1); + a2.setPostSubmit(true); + + // Both are ReviewDb, exact match is required. + ChangeBundle b1 = + new ChangeBundle( + c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB); + assertDiffs( + b1, + b2, + "postSubmit differs for PatchSetApproval.Key " + + c.getId() + + "%2C1,100,Code-Review:" + + " {false} != {true}"); + + // One NoteDb, postSubmit is ignored. + b1 = + new ChangeBundle( + c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB); + b2 = + new ChangeBundle(c, messages(), latest(c), approvals(a2), comments(), reviewers(), NOTE_DB); + assertNoDiffs(b1, b2); + assertNoDiffs(b2, b1); + + // postSubmit is not ignored if vote isn't 0. + a1.setValue((short) 1); + a2.setValue((short) 1); + assertDiffs( + b1, + b2, + "postSubmit differs for PatchSetApproval.Key " + + c.getId() + + "%2C1,100,Code-Review:" + + " {false} != {true}"); + assertDiffs( + b2, + b1, + "postSubmit differs for PatchSetApproval.Key " + + c.getId() + + "%2C1,100,Code-Review:" + + " {true} != {false}"); + } + + @Test + public void diffReviewers() throws Exception { + Change c = TestChanges.newChange(project, accountId); + Timestamp now = TimeUtil.nowTs(); + ReviewerSet r1 = reviewers(REVIEWER, new Account.Id(1), now); + ReviewerSet r2 = reviewers(REVIEWER, new Account.Id(2), now); + + ChangeBundle b1 = + new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r1, REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r2, REVIEW_DB); + assertNoDiffs(b1, b1); + assertNoDiffs(b2, b2); + assertDiffs(b1, b2, "reviewer sets differ: [1] only in A; [2] only in B"); + } + + @Test + public void diffReviewersIgnoresStateAndTimestamp() throws Exception { + Change c = TestChanges.newChange(project, accountId); + ReviewerSet r1 = reviewers(REVIEWER, new Account.Id(1), TimeUtil.nowTs()); + ReviewerSet r2 = reviewers(CC, new Account.Id(1), TimeUtil.nowTs()); + + ChangeBundle b1 = + new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r1, REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle(c, messages(), latest(c), approvals(), comments(), r2, REVIEW_DB); + assertNoDiffs(b1, b1); + assertNoDiffs(b2, b2); + } + + @Test + public void diffPatchLineCommentKeySets() throws Exception { + Change c = TestChanges.newChange(project, accountId); + int id = c.getId().get(); + PatchLineComment c1 = + new PatchLineComment( + new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename1"), "uuid1"), + 5, + accountId, + null, + TimeUtil.nowTs()); + PatchLineComment c2 = + new PatchLineComment( + new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename2"), "uuid2"), + 5, + accountId, + null, + TimeUtil.nowTs()); + + ChangeBundle b1 = + new ChangeBundle( + c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB); + + assertDiffs( + b1, + b2, + "PatchLineComment.Key sets differ:" + + " [" + + id + + ",1,filename1,uuid1] only in A;" + + " [" + + id + + ",1,filename2,uuid2] only in B"); + } + + @Test + public void diffPatchLineComments() throws Exception { + Change c = TestChanges.newChange(project, accountId); + PatchLineComment c1 = + new PatchLineComment( + new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename"), "uuid"), + 5, + accountId, + null, + TimeUtil.nowTs()); + PatchLineComment c2 = clone(c1); + ChangeBundle b1 = + new ChangeBundle( + c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB); + + assertNoDiffs(b1, b2); + + c2.setStatus(PatchLineComment.Status.PUBLISHED); + assertDiffs( + b1, + b2, + "status differs for PatchLineComment.Key " + c.getId() + ",1,filename,uuid: {d} != {P}"); + } + + @Test + public void diffPatchLineCommentsMixedSourcesAllowsSlop() throws Exception { + subWindowResolution(); + Change c = TestChanges.newChange(project, accountId); + PatchLineComment c1 = + new PatchLineComment( + new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename"), "uuid"), + 5, + accountId, + null, + truncateToSecond(TimeUtil.nowTs())); + PatchLineComment c2 = clone(c1); + c2.setWrittenOn(TimeUtil.nowTs()); + + // Both are ReviewDb, exact timestamp match is required. + ChangeBundle b1 = + new ChangeBundle( + c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB); + assertDiffs( + b1, + b2, + "writtenOn differs for PatchLineComment.Key " + + c.getId() + + ",1,filename,uuid:" + + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:03.0}"); + + // One NoteDb, slop is allowed. + b1 = + new ChangeBundle(c, messages(), latest(c), approvals(), comments(c1), reviewers(), NOTE_DB); + b2 = + new ChangeBundle( + c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB); + assertNoDiffs(b1, b2); + + // But not too much slop. + superWindowResolution(); + PatchLineComment c3 = clone(c1); + c3.setWrittenOn(TimeUtil.nowTs()); + b1 = + new ChangeBundle(c, messages(), latest(c), approvals(), comments(c1), reviewers(), NOTE_DB); + ChangeBundle b3 = + new ChangeBundle( + c, messages(), latest(c), approvals(), comments(c3), reviewers(), REVIEW_DB); + String msg = + "writtenOn differs for PatchLineComment.Key " + + c.getId() + + ",1,filename,uuid in NoteDb vs. ReviewDb:" + + " {2009-09-30 17:00:02.0} != {2009-09-30 17:00:10.0}"; + assertDiffs(b1, b3, msg); + assertDiffs(b3, b1, msg); + } + + @Test + public void diffPatchLineCommentsIgnoresCommentsOnInvalidPatchSet() throws Exception { + Change c = TestChanges.newChange(project, accountId); + PatchLineComment c1 = + new PatchLineComment( + new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename1"), "uuid1"), + 5, + accountId, + null, + TimeUtil.nowTs()); + PatchLineComment c2 = + new PatchLineComment( + new PatchLineComment.Key( + new Patch.Key(new PatchSet.Id(c.getId(), 0), "filename2"), "uuid2"), + 5, + accountId, + null, + TimeUtil.nowTs()); + + ChangeBundle b1 = + new ChangeBundle( + c, messages(), latest(c), approvals(), comments(c1, c2), reviewers(), REVIEW_DB); + ChangeBundle b2 = + new ChangeBundle( + c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB); + assertNoDiffs(b1, b2); + } + + private static void assertNoDiffs(ChangeBundle a, ChangeBundle b) { + assertThat(a.differencesFrom(b)).isEmpty(); + assertThat(b.differencesFrom(a)).isEmpty(); + } + + private static void assertDiffs(ChangeBundle a, ChangeBundle b, String first, String... rest) { + List<String> actual = a.differencesFrom(b); + if (actual.size() == 1 && rest.length == 0) { + // This error message is much easier to read. + assertThat(actual.get(0)).isEqualTo(first); + } else { + List<String> expected = new ArrayList<>(1 + rest.length); + expected.add(first); + Collections.addAll(expected, rest); + assertThat(actual).containsExactlyElementsIn(expected).inOrder(); + } + assertThat(a).isNotEqualTo(b); + } + + private static List<ChangeMessage> messages(ChangeMessage... ents) { + return Arrays.asList(ents); + } + + private static List<PatchSet> patchSets(PatchSet... ents) { + return Arrays.asList(ents); + } + + private static List<PatchSet> latest(Change c) { + PatchSet ps = new PatchSet(c.currentPatchSetId()); + ps.setCreatedOn(c.getLastUpdatedOn()); + return ImmutableList.of(ps); + } + + private static List<PatchSetApproval> approvals(PatchSetApproval... ents) { + return Arrays.asList(ents); + } + + private static ReviewerSet reviewers(Object... ents) { + checkArgument(ents.length % 3 == 0); + Table<ReviewerStateInternal, Account.Id, Timestamp> t = HashBasedTable.create(); + for (int i = 0; i < ents.length; i += 3) { + t.put((ReviewerStateInternal) ents[i], (Account.Id) ents[i + 1], (Timestamp) ents[i + 2]); + } + return ReviewerSet.fromTable(t); + } + + private static List<PatchLineComment> comments(PatchLineComment... ents) { + return Arrays.asList(ents); + } + + private static Change clone(Change ent) { + return clone(CHANGE_CODEC, ent); + } + + private static ChangeMessage clone(ChangeMessage ent) { + return clone(CHANGE_MESSAGE_CODEC, ent); + } + + private static PatchSet clone(PatchSet ent) { + return clone(PATCH_SET_CODEC, ent); + } + + private static PatchSetApproval clone(PatchSetApproval ent) { + return clone(PATCH_SET_APPROVAL_CODEC, ent); + } + + private static PatchLineComment clone(PatchLineComment ent) { + return clone(PATCH_LINE_COMMENT_CODEC, ent); + } + + private static <T> T clone(ProtobufCodec<T> codec, T obj) { + return codec.decode(codec.encodeToByteArray(obj)); + } +} |