Merge "CreateMergePatchSet: Implement 'author' to tweak Git commit author"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 8bd02a8..6b73482 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -7325,6 +7325,13 @@
 |`merge`              ||
 The detail of the source commit for merge as a link:#merge-input[MergeInput]
 entity.
+|`author`             |optional|
+An link:rest-api-accounts.html#account-input[AccountInput] entity
+that will set the author of the commit to create. The author must be
+specified as name/email combination.
+The caller needs "Forge Author" permission when using this field.
+This field does not affect the owner of the change, which will
+continue to use the identity of the caller.
 |==================================
 
 [[move-input]]
diff --git a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
index 53f5e07..734d7e9 100644
--- a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
+++ b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+
 public class MergePatchSetInput {
   public String subject;
   public boolean inheritParent;
   public String baseChange;
   public MergeInput merge;
+  public AccountInput author;
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index 8ac2140..af4bf69 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -55,6 +55,7 @@
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.restapi.project.CommitsCollection;
@@ -128,6 +129,13 @@
     psUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
     rsrc.permissions().check(ChangePermission.ADD_PATCH_SET);
+    if (in.author != null) {
+      permissionBackend
+          .currentUser()
+          .project(rsrc.getProject())
+          .ref(rsrc.getChange().getDest().branch())
+          .check(RefPermission.FORGE_AUTHOR);
+    }
 
     ProjectState projectState =
         projectCache.get(rsrc.getProject()).orElseThrow(illegalState(rsrc.getProject()));
@@ -137,6 +145,10 @@
     if (merge == null || Strings.isNullOrEmpty(merge.source)) {
       throw new BadRequestException("merge.source must be non-empty");
     }
+    if (in.author != null
+        && (Strings.isNullOrEmpty(in.author.email) || Strings.isNullOrEmpty(in.author.name))) {
+      throw new BadRequestException("Author must specify name and email");
+    }
     in.baseChange = Strings.nullToEmpty(in.baseChange).trim();
 
     PatchSet ps = psUtil.current(rsrc.getNotes());
@@ -166,7 +178,10 @@
 
       Timestamp now = TimeUtil.nowTs();
       IdentifiedUser me = user.get().asIdentifiedUser();
-      PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
+      PersonIdent author =
+          in.author == null
+              ? me.newCommitterIdent(now, serverTimeZone)
+              : new PersonIdent(in.author.name, in.author.email, now, serverTimeZone);
       CodeReviewCommit newCommit =
           createMergeCommit(
               in,
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index d4affb7..410cb41 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -47,7 +47,6 @@
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
@@ -142,14 +141,11 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.common.MergeInput;
-import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -183,7 +179,6 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
-import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -3189,407 +3184,6 @@
   }
 
   @Test
-  public void createMergePatchSet() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    String changeId = createChange().getChangeId();
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
-    currentMaster.assertOkStatus();
-    String parent = currentMaster.getCommit().getName();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    String subject = "update change by merge ps2";
-    in.subject = subject;
-
-    TestWorkInProgressStateChangedListener wipStateChangedListener =
-        new TestWorkInProgressStateChangedListener();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
-      ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
-      assertThat(changeInfo.subject).isEqualTo(in.subject);
-      assertThat(changeInfo.containsGitConflicts).isNull();
-      assertThat(changeInfo.workInProgress).isNull();
-    }
-    assertThat(wipStateChangedListener.invoked).isFalse();
-
-    // To get the revisions, we must retrieve the change with more change options.
-    ChangeInfo changeInfo =
-        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-    assertThat(changeInfo.revisions).hasSize(2);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isEqualTo(parent);
-
-    // Verify the message that has been posted on the change.
-    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
-    assertThat(messages).hasSize(2);
-    assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 2.");
-
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.message)
-        .contains(subject);
-  }
-
-  @Test
-  public void createMergePatchSet_SubjectCarriesOverByDefault() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    PushOneCommit.Result result = createChange();
-    String changeId = result.getChangeId();
-    String subject = result.getChange().change().getSubject();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result pushResult =
-        pushFactory.create(user.newIdent(), testRepo).to("refs/heads/dev");
-    pushResult.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = null;
-
-    // Ensure subject carries over
-    gApi.changes().id(changeId).createMergePatchSet(in);
-    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
-    assertThat(changeInfo.subject).isEqualTo(subject);
-  }
-
-  @Test
-  public void createMergePatchSet_Conflict() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    String changeId = createChange().getChangeId();
-
-    String fileName = "shared.txt";
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster =
-        pushFactory
-            .create(admin.newIdent(), testRepo, "change 1", fileName, "content 1")
-            .to("refs/heads/master");
-    currentMaster.assertOkStatus();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, "change 2", fileName, "content 2")
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2";
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(changeId).createMergePatchSet(in));
-    assertThat(thrown).hasMessageThat().isEqualTo("merge conflict(s):\n" + fileName);
-  }
-
-  @Test
-  public void createMergePatchSet_ConflictAllowed() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    String changeId = createChange().getChangeId();
-
-    String fileName = "shared.txt";
-    String sourceSubject = "source change";
-    String sourceContent = "source content";
-    String targetSubject = "target change";
-    String targetContent = "target content";
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster =
-        pushFactory
-            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
-            .to("refs/heads/master");
-    currentMaster.assertOkStatus();
-    String parent = currentMaster.getCommit().getName();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    mergeInput.allowConflicts = true;
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2";
-
-    TestWorkInProgressStateChangedListener wipStateChangedListener =
-        new TestWorkInProgressStateChangedListener();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
-      ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
-      assertThat(changeInfo.subject).isEqualTo(in.subject);
-      assertThat(changeInfo.containsGitConflicts).isTrue();
-      assertThat(changeInfo.workInProgress).isTrue();
-    }
-    assertThat(wipStateChangedListener.invoked).isTrue();
-    assertThat(wipStateChangedListener.wip).isTrue();
-
-    // To get the revisions, we must retrieve the change with more change options.
-    ChangeInfo changeInfo =
-        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-    assertThat(changeInfo.revisions).hasSize(2);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isEqualTo(parent);
-
-    // Verify that the file content in the created patch set is correct.
-    // We expect that it has conflict markers to indicate the conflict.
-    BinaryResult bin = gApi.changes().id(changeId).current().file(fileName).content();
-    ByteArrayOutputStream os = new ByteArrayOutputStream();
-    bin.writeTo(os);
-    String fileContent = new String(os.toByteArray(), UTF_8);
-    String sourceSha1 = abbreviateName(changeA.getCommit(), 6);
-    String targetSha1 = abbreviateName(currentMaster.getCommit(), 6);
-    assertThat(fileContent)
-        .isEqualTo(
-            "<<<<<<< TARGET BRANCH ("
-                + targetSha1
-                + " "
-                + targetSubject
-                + ")\n"
-                + targetContent
-                + "\n"
-                + "=======\n"
-                + sourceContent
-                + "\n"
-                + ">>>>>>> SOURCE BRANCH ("
-                + sourceSha1
-                + " "
-                + sourceSubject
-                + ")\n");
-
-    // Verify the message that has been posted on the change.
-    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
-    assertThat(messages).hasSize(2);
-    assertThat(Iterables.getLast(messages).message)
-        .isEqualTo(
-            "Uploaded patch set 2.\n\n"
-                + "The following files contain Git conflicts:\n"
-                + "* "
-                + fileName
-                + "\n");
-  }
-
-  @Test
-  public void createMergePatchSet_ConflictAllowedNotSupportedByMergeStrategy() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    String changeId = createChange().getChangeId();
-
-    String fileName = "shared.txt";
-    String sourceSubject = "source change";
-    String sourceContent = "source content";
-    String targetSubject = "target change";
-    String targetContent = "target content";
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster =
-        pushFactory
-            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
-            .to("refs/heads/master");
-    currentMaster.assertOkStatus();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    mergeInput.allowConflicts = true;
-    mergeInput.strategy = "simple-two-way-in-core";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2";
-
-    BadRequestException ex =
-        assertThrows(
-            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
-    assertThat(ex)
-        .hasMessageThat()
-        .isEqualTo(
-            "merge with conflicts is not supported with merge strategy: " + mergeInput.strategy);
-  }
-
-  @Test
-  public void createMergePatchSetInheritParent() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    String parent = r.getCommit().getParent(0).getName();
-
-    // advance master branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
-    currentMaster.assertOkStatus();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2 inherit parent of ps1";
-    in.inheritParent = true;
-    gApi.changes().id(changeId).createMergePatchSet(in);
-    ChangeInfo changeInfo =
-        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-
-    assertThat(changeInfo.revisions).hasSize(2);
-    assertThat(changeInfo.subject).isEqualTo(in.subject);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isEqualTo(parent);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isNotEqualTo(currentMaster.getCommit().getName());
-  }
-
-  @Test
-  public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("foo");
-    createBranch("bar");
-
-    // Create a merged commit on 'foo' branch.
-    merge(createChange("refs/for/foo"));
-
-    // Create the base change on 'bar' branch.
-    testRepo.reset(initialHead);
-    String baseChange = createChange("refs/for/bar").getChangeId();
-    gApi.changes().id(baseChange).setPrivate(true, "set private");
-
-    // Create the destination change on 'master' branch.
-    requestScopeOperations.setApiUser(user.id());
-    testRepo.reset(initialHead);
-    String changeId = createChange().getChangeId();
-
-    UnprocessableEntityException thrown =
-        assertThrows(
-            UnprocessableEntityException.class,
-            () ->
-                gApi.changes()
-                    .id(changeId)
-                    .createMergePatchSet(createMergePatchSetInput(baseChange)));
-    assertThat(thrown).hasMessageThat().contains("Read not permitted for " + baseChange);
-  }
-
-  @Test
-  public void createMergePatchSetBaseOnChange() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("foo");
-    createBranch("bar");
-
-    // Create a merged commit on 'foo' branch.
-    merge(createChange("refs/for/foo"));
-
-    // Create the base change on 'bar' branch.
-    testRepo.reset(initialHead);
-    PushOneCommit.Result result = createChange("refs/for/bar");
-    String baseChange = result.getChangeId();
-    String expectedParent = result.getCommit().getName();
-
-    // Create the destination change on 'master' branch.
-    testRepo.reset(initialHead);
-    String changeId = createChange().getChangeId();
-
-    gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange));
-
-    ChangeInfo changeInfo =
-        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-    assertThat(changeInfo.revisions).hasSize(2);
-    assertThat(changeInfo.subject).isEqualTo("create ps2");
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isEqualTo(expectedParent);
-  }
-
-  @Test
-  public void createMergePatchSetWithUnupportedMergeStrategy() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    String changeId = createChange().getChangeId();
-
-    String fileName = "shared.txt";
-    String sourceSubject = "source change";
-    String sourceContent = "source content";
-    String targetSubject = "target change";
-    String targetContent = "target content";
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster =
-        pushFactory
-            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
-            .to("refs/heads/master");
-    currentMaster.assertOkStatus();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    mergeInput.strategy = "unsupported-strategy";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2";
-
-    BadRequestException ex =
-        assertThrows(
-            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
-    assertThat(ex).hasMessageThat().isEqualTo("invalid merge strategy: " + mergeInput.strategy);
-  }
-
-  private MergePatchSetInput createMergePatchSetInput(String baseChange) {
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "foo";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "create ps2";
-    in.inheritParent = false;
-    in.baseChange = baseChange;
-    return in;
-  }
-
-  @Test
   public void checkLabelsForUnsubmittedChange() throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
new file mode 100644
index 0000000..8b77d013
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
@@ -0,0 +1,644 @@
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.common.MergePatchSetInput;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.inject.Inject;
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CreateMergePatchSetIT extends AbstractDaemonTest {
+
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Before
+  public void setUp() {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+  }
+
+  @Test
+  public void createMergePatchSet() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+
+    TestWorkInProgressStateChangedListener wipStateChangedListener =
+        new TestWorkInProgressStateChangedListener();
+    try (ExtensionRegistry.Registration registration =
+        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+      ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
+      assertThat(changeInfo.subject).isEqualTo(in.subject);
+      assertThat(changeInfo.containsGitConflicts).isNull();
+      assertThat(changeInfo.workInProgress).isNull();
+    }
+    assertThat(wipStateChangedListener.invoked).isFalse();
+
+    // To get the revisions, we must retrieve the change with more change options.
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(parent);
+
+    // Verify the message that has been posted on the change.
+    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+    assertThat(messages).hasSize(2);
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 2.");
+
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.message)
+        .contains(subject);
+  }
+
+  @Test
+  public void createMergePatchSet_SubjectCarriesOverByDefault() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String subject = result.getChange().change().getSubject();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result pushResult =
+        pushFactory.create(user.newIdent(), testRepo).to("refs/heads/dev");
+    pushResult.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = null;
+
+    // Ensure subject carries over
+    gApi.changes().id(changeId).createMergePatchSet(in);
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    assertThat(changeInfo.subject).isEqualTo(subject);
+  }
+
+  @Test
+  public void createMergePatchSet_Conflict() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    String fileName = "shared.txt";
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "change 1", fileName, "content 1")
+            .to("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change 2", fileName, "content 2")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(thrown).hasMessageThat().isEqualTo("merge conflict(s):\n" + fileName);
+  }
+
+  @Test
+  public void createMergePatchSet_ConflictAllowed() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    String fileName = "shared.txt";
+    String sourceSubject = "source change";
+    String sourceContent = "source content";
+    String targetSubject = "target change";
+    String targetContent = "target content";
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster =
+        pushFactory
+            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+            .to("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    mergeInput.allowConflicts = true;
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+
+    TestWorkInProgressStateChangedListener wipStateChangedListener =
+        new TestWorkInProgressStateChangedListener();
+    try (ExtensionRegistry.Registration registration =
+        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+      ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
+      assertThat(changeInfo.subject).isEqualTo(in.subject);
+      assertThat(changeInfo.containsGitConflicts).isTrue();
+      assertThat(changeInfo.workInProgress).isTrue();
+    }
+    assertThat(wipStateChangedListener.invoked).isTrue();
+    assertThat(wipStateChangedListener.wip).isTrue();
+
+    // To get the revisions, we must retrieve the change with more change options.
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(parent);
+
+    // Verify that the file content in the created patch set is correct.
+    // We expect that it has conflict markers to indicate the conflict.
+    BinaryResult bin = gApi.changes().id(changeId).current().file(fileName).content();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String fileContent = new String(os.toByteArray(), UTF_8);
+    String sourceSha1 = abbreviateName(changeA.getCommit(), 6);
+    String targetSha1 = abbreviateName(currentMaster.getCommit(), 6);
+    assertThat(fileContent)
+        .isEqualTo(
+            "<<<<<<< TARGET BRANCH ("
+                + targetSha1
+                + " "
+                + targetSubject
+                + ")\n"
+                + targetContent
+                + "\n"
+                + "=======\n"
+                + sourceContent
+                + "\n"
+                + ">>>>>>> SOURCE BRANCH ("
+                + sourceSha1
+                + " "
+                + sourceSubject
+                + ")\n");
+
+    // Verify the message that has been posted on the change.
+    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+    assertThat(messages).hasSize(2);
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            "Uploaded patch set 2.\n\n"
+                + "The following files contain Git conflicts:\n"
+                + "* "
+                + fileName
+                + "\n");
+  }
+
+  @Test
+  public void createMergePatchSet_ConflictAllowedNotSupportedByMergeStrategy() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    String fileName = "shared.txt";
+    String sourceSubject = "source change";
+    String sourceContent = "source content";
+    String targetSubject = "target change";
+    String targetContent = "target content";
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster =
+        pushFactory
+            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+            .to("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    mergeInput.allowConflicts = true;
+    mergeInput.strategy = "simple-two-way-in-core";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex)
+        .hasMessageThat()
+        .isEqualTo(
+            "merge with conflicts is not supported with merge strategy: " + mergeInput.strategy);
+  }
+
+  @Test
+  public void createMergePatchSetInheritParent() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String parent = r.getCommit().getParent(0).getName();
+
+    // advance master branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2 inherit parent of ps1";
+    in.inheritParent = true;
+    gApi.changes().id(changeId).createMergePatchSet(in);
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.subject).isEqualTo(in.subject);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(parent);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isNotEqualTo(currentMaster.getCommit().getName());
+  }
+
+  @Test
+  public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "foo"));
+    createBranch(BranchNameKey.create(project, "bar"));
+
+    // Create a merged commit on 'foo' branch.
+    merge(createChange("refs/for/foo"));
+
+    // Create the base change on 'bar' branch.
+    testRepo.reset(initialHead);
+    String baseChange = createChange("refs/for/bar").getChangeId();
+    gApi.changes().id(baseChange).setPrivate(true, "set private");
+
+    // Create the destination change on 'master' branch.
+    requestScopeOperations.setApiUser(user.id());
+    testRepo.reset(initialHead);
+    String changeId = createChange().getChangeId();
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .createMergePatchSet(createMergePatchSetInput(baseChange)));
+    assertThat(thrown).hasMessageThat().contains("Read not permitted for " + baseChange);
+  }
+
+  @Test
+  public void createMergePatchSetBaseOnChange() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "foo"));
+    createBranch(BranchNameKey.create(project, "bar"));
+
+    // Create a merged commit on 'foo' branch.
+    merge(createChange("refs/for/foo"));
+
+    // Create the base change on 'bar' branch.
+    testRepo.reset(initialHead);
+    PushOneCommit.Result result = createChange("refs/for/bar");
+    String baseChange = result.getChangeId();
+    String expectedParent = result.getCommit().getName();
+
+    // Create the destination change on 'master' branch.
+    testRepo.reset(initialHead);
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange));
+
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.subject).isEqualTo("create ps2");
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(expectedParent);
+  }
+
+  @Test
+  public void createMergePatchSetWithUnupportedMergeStrategy() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    String fileName = "shared.txt";
+    String sourceSubject = "source change";
+    String sourceContent = "source content";
+    String targetSubject = "target change";
+    String targetContent = "target content";
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster =
+        pushFactory
+            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+            .to("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    mergeInput.strategy = "unsupported-strategy";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex).hasMessageThat().isEqualTo("invalid merge strategy: " + mergeInput.strategy);
+  }
+
+  @Test
+  public void createMergePatchSetWithOtherAuthor() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+    in.author = new AccountInput();
+    in.author.name = "Other Author";
+    in.author.email = "otherauthor@example.com";
+    gApi.changes().id(changeId).createMergePatchSet(in);
+
+    // To get the revisions, we must retrieve the change with more change options.
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(parent);
+
+    // Verify the message that has been posted on the change.
+    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+    assertThat(messages).hasSize(2);
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 2.");
+
+    CommitInfo commitInfo = changeInfo.revisions.get(changeInfo.currentRevision).commit;
+    assertThat(commitInfo.message).contains(subject);
+    assertThat(commitInfo.author.name).isEqualTo("Other Author");
+    assertThat(commitInfo.author.email).isEqualTo("otherauthor@example.com");
+  }
+
+  @Test
+  public void createMergePatchSetWithSpecificAuthorButNoForgeAuthorPermission() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+    in.author = new AccountInput();
+    in.author.name = "Foo";
+    in.author.email = "foo@example.com";
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(
+            TestProjectUpdate.permissionKey(Permission.FORGE_AUTHOR)
+                .ref("refs/*")
+                .group(REGISTERED_USERS))
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    AuthException ex =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex).hasMessageThat().isEqualTo("not permitted: forge author on refs/heads/master");
+  }
+
+  @Test
+  public void createMergePatchSetWithMissingNameFailsWithBadRequestException() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+    in.author = new AccountInput();
+    in.author.name = "Foo";
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex).hasMessageThat().isEqualTo("Author must specify name and email");
+  }
+
+  @Test
+  public void createMergePatchSetWithMissingEmailFailsWithBadRequestException() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+    in.author = new AccountInput();
+    in.author.email = "Foo";
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex).hasMessageThat().isEqualTo("Author must specify name and email");
+  }
+
+  private MergePatchSetInput createMergePatchSetInput(String baseChange) {
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "foo";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "create ps2";
+    in.inheritParent = false;
+    in.baseChange = baseChange;
+    return in;
+  }
+
+  private static class TestWorkInProgressStateChangedListener
+      implements WorkInProgressStateChangedListener {
+    boolean invoked;
+    Boolean wip;
+
+    @Override
+    public void onWorkInProgressStateChanged(Event event) {
+      this.invoked = true;
+      this.wip =
+          event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
+    }
+  }
+}