Handle diamond merges correctly.

If we have branches top, left, right, and bottom, and left == right,
then creating a change on top should create two changes with the same
sha on left and right, and only a single change on bottom.

top -> left -> bottom, and top -> right -> bottom simultaneously.

Change-Id: I8813cefb7cb7052b435dbef4e1af422c516e13a0
diff --git a/BUILD b/BUILD
index 0914e7b..1c3873a 100644
--- a/BUILD
+++ b/BUILD
@@ -23,5 +23,6 @@
     tags = ["automerger"],
     deps = PLUGIN_DEPS + PLUGIN_TEST_DEPS + [
         ":automerger__plugin",
+        "@commons_net//jar",
     ],
 )
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreator.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreator.java
index 766529d..b12a0f3 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreator.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreator.java
@@ -365,16 +365,7 @@
       String upstreamRevision, String topic, String downstreamBranch)
       throws RestApiException, InvalidQueryParameterException {
     List<Integer> downstreamChangeNumbers = new ArrayList<Integer>();
-    QueryBuilder queryBuilder = new QueryBuilder();
-    queryBuilder.addParameter("topic", topic);
-    queryBuilder.addParameter("branch", downstreamBranch);
-    queryBuilder.addParameter("status", "open");
-    // get changes in same topic and check if their parent is upstreamRevision
-    List<ChangeInfo> changes =
-        gApi.changes()
-            .query(queryBuilder.get())
-            .withOptions(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_COMMIT)
-            .get();
+    List<ChangeInfo> changes = getChangesInTopic(topic, downstreamBranch);
 
     for (ChangeInfo change : changes) {
       String changeRevision = change.currentRevision;
@@ -396,11 +387,20 @@
    * @param sdsMergeInput Input containing metadata for the merge.
    * @throws RestApiException
    * @throws ConfigInvalidException
+   * @throws InvalidQueryParameterException
    */
   public void createSingleDownstreamMerge(SingleDownstreamMergeInput sdsMergeInput)
-      throws RestApiException, ConfigInvalidException {
+      throws RestApiException, ConfigInvalidException, InvalidQueryParameterException {
     String currentTopic = getOrSetTopic(sdsMergeInput.changeNumber, sdsMergeInput.topic);
 
+    if (isAlreadyMerged(sdsMergeInput, currentTopic)) {
+      log.info(
+          "Commit {} already merged into {}, not automerging again.",
+          sdsMergeInput.currentRevision,
+          sdsMergeInput.downstreamBranch);
+      return;
+    }
+
     MergeInput mergeInput = new MergeInput();
     mergeInput.source = sdsMergeInput.currentRevision;
     mergeInput.strategy = "recursive";
@@ -573,4 +573,34 @@
     abandonInput.message = "Merge parent updated; abandoning due to upstream conflict.";
     gApi.changes().id(changeNumber).abandon(abandonInput);
   }
+
+  private List<ChangeInfo> getChangesInTopic(String topic, String downstreamBranch)
+      throws InvalidQueryParameterException, RestApiException {
+    QueryBuilder queryBuilder = new QueryBuilder();
+    queryBuilder.addParameter("topic", topic);
+    queryBuilder.addParameter("branch", downstreamBranch);
+    queryBuilder.addParameter("status", "open");
+    return gApi.changes()
+        .query(queryBuilder.get())
+        .withOptions(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_COMMIT)
+        .get();
+  }
+
+  private boolean isAlreadyMerged(SingleDownstreamMergeInput sdsMergeInput, String currentTopic)
+      throws InvalidQueryParameterException, RestApiException {
+    // If we've already merged this commit to this branch, don't do it again.
+    List<ChangeInfo> changes = getChangesInTopic(currentTopic, sdsMergeInput.downstreamBranch);
+    for (ChangeInfo change : changes) {
+      if (change.branch.equals(sdsMergeInput.downstreamBranch)) {
+        List<CommitInfo> parents = change.revisions.get(change.currentRevision).commit.parents;
+        if (parents.size() > 1) {
+          String secondParent = parents.get(1).commit;
+          if (secondParent.equals(sdsMergeInput.currentRevision)) {
+            return true;
+          }
+        }
+      }
+    }
+    return false;
+  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreatorIT.java b/src/test/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreatorIT.java
index 69893c5..1368a3f 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreatorIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreatorIT.java
@@ -32,13 +32,16 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.testutil.TestTimeUtil;
 import java.io.InputStream;
 import java.io.InputStreamReader;
+import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.EnumSet;
 import java.util.List;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -91,6 +94,136 @@
   }
 
   @Test
+  public void testDiamondMerge() throws Exception {
+    Project.NameKey manifestNameKey = defaultSetup();
+    // Create initial change
+    PushOneCommit.Result initialResult = createChange("subject", "filename", "echo Hello");
+    // Project name is scoped by test, so we need to get it from our initial change
+    String projectName = initialResult.getChange().project().get();
+    createBranch(new Branch.NameKey(projectName, "left"));
+    createBranch(new Branch.NameKey(projectName, "right"));
+    initialResult.assertOkStatus();
+    merge(initialResult);
+    // Reset to create a sibling
+    ObjectId initial = repo().exactRef("HEAD").getLeaf().getObjectId();
+    testRepo.reset(initial);
+    // Make left != right
+    PushOneCommit.Result left =
+        createChange(
+            testRepo, "left", "subject", "filename", "echo \"Hello asdfsd World\"", "randtopic");
+    left.assertOkStatus();
+    merge(left);
+
+    String leftRevision = gApi.projects().name(projectName).branch("left").get().revision;
+    String rightRevision = gApi.projects().name(projectName).branch("right").get().revision;
+    // For this test, right != left
+    assertThat(leftRevision).isNotEqualTo(rightRevision);
+    createBranch(new Branch.NameKey(projectName, "bottom"));
+    pushDiamondConfig(manifestNameKey.get(), projectName);
+    // After we upload our config, we upload a new patchset to create the downstreams
+    PushOneCommit.Result result = createChange("subject", "filename2", "echo Hello", "sometopic");
+    result.assertOkStatus();
+    // Check that there are the correct number of changes in the topic
+    List<ChangeInfo> changesInTopic =
+        gApi.changes().query("topic: " + gApi.changes().id(result.getChangeId()).topic()).get();
+    assertThat(changesInTopic).hasSize(5);
+    // +2 and submit
+    merge(result);
+    List<ChangeInfo> sortedChanges = sortedChanges(changesInTopic);
+
+    // Should create two changes on bottom, since left and right are different.
+    ChangeInfo bottomChangeInfoA = sortedChanges.get(0);
+    assertThat(bottomChangeInfoA.branch).isEqualTo("bottom");
+    ChangeApi bottomChangeA = gApi.changes().id(bottomChangeInfoA._number);
+    assertThat(getVote(bottomChangeA, "Code-Review").value).isEqualTo(2);
+    assertThat(getVote(bottomChangeA, "Code-Review").tag).isEqualTo("autogenerated:Automerger");
+
+    ChangeInfo bottomChangeInfoB = sortedChanges.get(1);
+    assertThat(bottomChangeInfoB.branch).isEqualTo("bottom");
+    ChangeApi bottomChangeB = gApi.changes().id(bottomChangeInfoB._number);
+    assertThat(getVote(bottomChangeB, "Code-Review").value).isEqualTo(2);
+    assertThat(getVote(bottomChangeB, "Code-Review").tag).isEqualTo("autogenerated:Automerger");
+
+    ChangeInfo leftChangeInfo = sortedChanges.get(2);
+    assertThat(leftChangeInfo.branch).isEqualTo("left");
+    ChangeApi leftChange = gApi.changes().id(leftChangeInfo._number);
+    assertThat(getVote(leftChange, "Code-Review").value).isEqualTo(2);
+    assertThat(getVote(leftChange, "Code-Review").tag).isEqualTo("autogenerated:Automerger");
+
+    ChangeInfo masterChangeInfo = sortedChanges.get(3);
+    assertThat(masterChangeInfo.branch).isEqualTo("master");
+    ChangeApi masterChange = gApi.changes().id(masterChangeInfo._number);
+    assertThat(getVote(masterChange, "Code-Review").value).isEqualTo(2);
+    assertThat(getVote(masterChange, "Code-Review").tag).isEqualTo(null);
+
+    ChangeInfo rightChangeInfo = sortedChanges.get(4);
+    assertThat(rightChangeInfo.branch).isEqualTo("right");
+    ChangeApi rightChange = gApi.changes().id(rightChangeInfo._number);
+    assertThat(getVote(rightChange, "Code-Review").value).isEqualTo(2);
+    assertThat(getVote(rightChange, "Code-Review").tag).isEqualTo("autogenerated:Automerger");
+  }
+
+  @Test
+  public void testDiamondMerge_identicalDiamondSides() throws Exception {
+    Project.NameKey manifestNameKey = defaultSetup();
+    // Create initial change
+    PushOneCommit.Result initialResult = createChange("subject", "filename", "echo Hello");
+    // Project name is scoped by test, so we need to get it from our initial change
+    String projectName = initialResult.getChange().project().get();
+    createBranch(new Branch.NameKey(projectName, "left"));
+    createBranch(new Branch.NameKey(projectName, "right"));
+    initialResult.assertOkStatus();
+    merge(initialResult);
+
+    String leftRevision = gApi.projects().name(projectName).branch("left").get().revision;
+    String rightRevision = gApi.projects().name(projectName).branch("right").get().revision;
+    // For this test, right == left
+    assertThat(leftRevision).isEqualTo(rightRevision);
+    createBranch(new Branch.NameKey(projectName, "bottom"));
+    pushDiamondConfig(manifestNameKey.get(), projectName);
+
+    // Freeze time so that the merge commit from left->bottom and right->bottom have same SHA
+    TestTimeUtil.resetWithClockStep(0, TimeUnit.MILLISECONDS);
+    TestTimeUtil.setClock(new Timestamp(TestTimeUtil.START.getMillis()));
+    // After we upload our config, we upload a new patchset to create the downstreams.
+    PushOneCommit.Result result = createChange("subject", "filename2", "echo Hello", "sometopic");
+    TestTimeUtil.useSystemTime();
+    result.assertOkStatus();
+
+    List<ChangeInfo> changesInTopic =
+        gApi.changes().query("topic: " + gApi.changes().id(result.getChangeId()).topic()).get();
+    assertThat(changesInTopic).hasSize(4);
+    // +2 and submit
+    merge(result);
+    List<ChangeInfo> sortedChanges = sortedChanges(changesInTopic);
+
+    // Should create two changes on bottom, since left and right are different.
+    ChangeInfo bottomChangeInfo = sortedChanges.get(0);
+    assertThat(bottomChangeInfo.branch).isEqualTo("bottom");
+    ChangeApi bottomChange = gApi.changes().id(bottomChangeInfo._number);
+    assertThat(getVote(bottomChange, "Code-Review").value).isEqualTo(2);
+    assertThat(getVote(bottomChange, "Code-Review").tag).isEqualTo("autogenerated:Automerger");
+
+    ChangeInfo leftChangeInfo = sortedChanges.get(1);
+    assertThat(leftChangeInfo.branch).isEqualTo("left");
+    ChangeApi leftChange = gApi.changes().id(leftChangeInfo._number);
+    assertThat(getVote(leftChange, "Code-Review").value).isEqualTo(2);
+    assertThat(getVote(leftChange, "Code-Review").tag).isEqualTo("autogenerated:Automerger");
+
+    ChangeInfo masterChangeInfo = sortedChanges.get(2);
+    assertThat(masterChangeInfo.branch).isEqualTo("master");
+    ChangeApi masterChange = gApi.changes().id(masterChangeInfo._number);
+    assertThat(getVote(masterChange, "Code-Review").value).isEqualTo(2);
+    assertThat(getVote(masterChange, "Code-Review").tag).isEqualTo(null);
+
+    ChangeInfo rightChangeInfo = sortedChanges.get(3);
+    assertThat(rightChangeInfo.branch).isEqualTo("right");
+    ChangeApi rightChange = gApi.changes().id(rightChangeInfo._number);
+    assertThat(getVote(rightChange, "Code-Review").value).isEqualTo(2);
+    assertThat(getVote(rightChange, "Code-Review").tag).isEqualTo("autogenerated:Automerger");
+  }
+
+  @Test
   public void testBlankMerge() throws Exception {
     Project.NameKey manifestNameKey = defaultSetup();
     // Create initial change
@@ -439,6 +572,28 @@
     }
   }
 
+  private void pushDiamondConfig(String manifestName, String project) throws Exception {
+    TestRepository<InMemoryRepository> allProjectRepo = cloneProject(allProjects, admin);
+    GitUtil.fetch(allProjectRepo, RefNames.REFS_CONFIG + ":config");
+    allProjectRepo.reset("config");
+    try (InputStream in = getClass().getResourceAsStream("diamond.config")) {
+      String resourceString = CharStreams.toString(new InputStreamReader(in, Charsets.UTF_8));
+      Config cfg = new Config();
+      cfg.fromText(resourceString);
+      // Update manifest project path to the result of createProject(resourceName), since it is
+      // scoped to the test method
+      cfg.setString("global", null, "manifestProject", manifestName);
+      cfg.setString("automerger", "master:left", "setProjects", project);
+      cfg.setString("automerger", "master:right", "setProjects", project);
+      cfg.setString("automerger", "left:bottom", "setProjects", project);
+      cfg.setString("automerger", "right:bottom", "setProjects", project);
+      PushOneCommit push =
+          pushFactory.create(
+              db, admin.getIdent(), allProjectRepo, "Subject", "automerger.config", cfg.toText());
+      push.to(RefNames.REFS_CONFIG).assertOkStatus();
+    }
+  }
+
   private ApprovalInfo getVote(ChangeApi change, String label) throws RestApiException {
     return change.get(EnumSet.of(ListChangesOption.DETAILED_LABELS)).labels.get(label).all.get(0);
   }
diff --git a/src/test/resources/com/googlesource/gerrit/plugins/automerger/diamond.config b/src/test/resources/com/googlesource/gerrit/plugins/automerger/diamond.config
new file mode 100644
index 0000000..901c731
--- /dev/null
+++ b/src/test/resources/com/googlesource/gerrit/plugins/automerger/diamond.config
@@ -0,0 +1,11 @@
+[automerger "master:left"]
+  setProjects = platform/some/project
+[automerger "master:right"]
+  setProjects = platform/some/project
+[automerger "left:bottom"]
+  setProjects = platform/some/project
+[automerger "right:bottom"]
+  setProjects = platform/some/project
+[global]
+  manifestFile = default.xml
+  manifestProject = platform/manifest
\ No newline at end of file