Factor out logic to create changes of a certain kind in tests

This lets us reuse this logic elsewhere.

Change-Id: Ia732d0e4ce4cfefe38918984fc65ff25ead19c18
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java
new file mode 100644
index 0000000..cb987da
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java
@@ -0,0 +1,348 @@
+// Copyright (C) 2021 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.acceptance.testsuite.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.inject.Inject;
+import java.util.List;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/** Helper to create changes of a certain {@link ChangeKind}. */
+public class ChangeKindCreator {
+  private GerritApi gApi;
+  private PushOneCommit.Factory pushFactory;
+  private RequestScopeOperations requestScopeOperations;
+  private ProjectOperations projectOperations;
+
+  @Inject
+  private ChangeKindCreator(
+      GerritApi gApi,
+      PushOneCommit.Factory pushFactory,
+      RequestScopeOperations requestScopeOperations,
+      ProjectOperations projectOperations) {
+    this.gApi = gApi;
+    this.pushFactory = pushFactory;
+    this.requestScopeOperations = requestScopeOperations;
+    this.projectOperations = projectOperations;
+  }
+
+  /** Creates a change with the given {@link ChangeKind} and returns the change id. */
+  public String createChange(
+      ChangeKind kind, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    switch (kind) {
+      case NO_CODE_CHANGE:
+      case REWORK:
+      case TRIVIAL_REBASE:
+      case NO_CHANGE:
+        return createChange(testRepo, user).getChangeId();
+      case MERGE_FIRST_PARENT_UPDATE:
+        return createChangeForMergeCommit(testRepo, user);
+      default:
+        throw new IllegalStateException("unexpected change kind: " + kind);
+    }
+  }
+
+  /** Updates a change with the given {@link ChangeKind}. */
+  public void updateChange(
+      String changeId,
+      ChangeKind changeKind,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    switch (changeKind) {
+      case NO_CODE_CHANGE:
+        noCodeChange(changeId, testRepo, user, project);
+        return;
+      case REWORK:
+        rework(changeId, testRepo, user, project);
+        return;
+      case TRIVIAL_REBASE:
+        trivialRebase(changeId, testRepo, user, project);
+        return;
+      case MERGE_FIRST_PARENT_UPDATE:
+        updateFirstParent(changeId, testRepo, user);
+        return;
+      case NO_CHANGE:
+        noChange(changeId, testRepo, user, project);
+        return;
+      default:
+        assertWithMessage("unexpected change kind: " + changeKind).fail();
+    }
+  }
+
+  /**
+   * Creates a cherry pick of the provided change with the given {@link ChangeKind} and returns the
+   * change id.
+   */
+  public String cherryPick(
+      String changeId,
+      ChangeKind changeKind,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    switch (changeKind) {
+      case REWORK:
+      case TRIVIAL_REBASE:
+        break;
+      case NO_CODE_CHANGE:
+      case NO_CHANGE:
+      case MERGE_FIRST_PARENT_UPDATE:
+      default:
+        assertWithMessage("unexpected change kind: " + changeKind).fail();
+    }
+
+    testRepo.reset(projectOperations.project(project).getHead("master"));
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                user.newIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                "other.txt",
+                "new content " + System.nanoTime())
+            .to("refs/for/master");
+    r.assertOkStatus();
+    vote(user, r.getChangeId(), 2, 1);
+    merge(r);
+
+    String subject =
+        ChangeKind.TRIVIAL_REBASE.equals(changeKind)
+            ? PushOneCommit.SUBJECT
+            : "Reworked change " + System.nanoTime();
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "master";
+    in.message = String.format("%s\n\nChange-Id: %s", subject, changeId);
+    ChangeInfo c = gApi.changes().id(changeId).current().cherryPick(in).get();
+    return c.changeId;
+  }
+
+  /** Creates a change that is a merge {@link ChangeKind} and returns the change id. */
+  public String createChangeForMergeCommit(
+      TestRepository<InMemoryRepository> testRepo, TestAccount user) throws Exception {
+    ObjectId initial = testRepo.getRepository().exactRef(HEAD).getLeaf().getObjectId();
+
+    PushOneCommit.Result parent1 = createChange("parent 1", "p1.txt", "content 1", testRepo, user);
+
+    testRepo.reset(initial);
+    PushOneCommit.Result parent2 = createChange("parent 2", "p2.txt", "content 2", testRepo, user);
+
+    testRepo.reset(parent1.getCommit());
+
+    PushOneCommit merge = pushFactory.create(user.newIdent(), testRepo);
+    merge.setParents(ImmutableList.of(parent1.getCommit(), parent2.getCommit()));
+    PushOneCommit.Result result = merge.to("refs/for/master");
+    result.assertOkStatus();
+    return result.getChangeId();
+  }
+
+  /** Update the first parent of a merge. */
+  public void updateFirstParent(
+      String changeId, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    ChangeInfo c = detailedChange(changeId);
+    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
+    String parent1 = parents.get(0).commit;
+    String parent2 = parents.get(1).commit;
+    RevCommit commitParent2 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent2));
+
+    testRepo.reset(parent1);
+    PushOneCommit.Result newParent1 =
+        createChange("new parent 1", "p1-1.txt", "content 1-1", testRepo, user);
+
+    PushOneCommit merge = pushFactory.create(user.newIdent(), testRepo, changeId);
+    merge.setParents(ImmutableList.of(newParent1.getCommit(), commitParent2));
+    PushOneCommit.Result result = merge.to("refs/for/master");
+    result.assertOkStatus();
+
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.MERGE_FIRST_PARENT_UPDATE);
+  }
+
+  /** Update the second parent of a merge. */
+  public void updateSecondParent(
+      String changeId, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    ChangeInfo c = detailedChange(changeId);
+    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
+    String parent1 = parents.get(0).commit;
+    String parent2 = parents.get(1).commit;
+    RevCommit commitParent1 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent1));
+
+    testRepo.reset(parent2);
+    PushOneCommit.Result newParent2 =
+        createChange("new parent 2", "p2-2.txt", "content 2-2", testRepo, user);
+
+    PushOneCommit merge = pushFactory.create(user.newIdent(), testRepo, changeId);
+    merge.setParents(ImmutableList.of(commitParent1, newParent2.getCommit()));
+    PushOneCommit.Result result = merge.to("refs/for/master");
+    result.assertOkStatus();
+
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.REWORK);
+  }
+
+  private void noCodeChange(
+      String changeId,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    TestRepository<?>.CommitBuilder commitBuilder =
+        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
+    commitBuilder
+        .message("New subject " + System.nanoTime())
+        .author(user.newIdent())
+        .committer(new PersonIdent(user.newIdent(), testRepo.getDate()));
+    commitBuilder.create();
+    GitUtil.pushHead(testRepo, "refs/for/master", false);
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.NO_CODE_CHANGE);
+  }
+
+  private void noChange(
+      String changeId,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    String commitMessage = change.revisions.get(change.currentRevision).commit.message;
+
+    TestRepository<?>.CommitBuilder commitBuilder =
+        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
+    commitBuilder
+        .message(commitMessage)
+        .author(user.newIdent())
+        .committer(new PersonIdent(user.newIdent(), testRepo.getDate()));
+    commitBuilder.create();
+    GitUtil.pushHead(testRepo, "refs/for/master", false);
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.NO_CHANGE);
+  }
+
+  private void rework(
+      String changeId,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            user.newIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            PushOneCommit.FILE_NAME,
+            "new content " + System.nanoTime(),
+            changeId);
+    push.to("refs/for/master").assertOkStatus();
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.REWORK);
+  }
+
+  private void trivialRebase(
+      String changeId,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    testRepo.reset(projectOperations.project(project).getHead("master"));
+    PushOneCommit push =
+        pushFactory.create(
+            user.newIdent(),
+            testRepo,
+            "Other Change",
+            "a" + System.nanoTime() + ".txt",
+            PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    ReviewInput in = new ReviewInput().label(LabelId.CODE_REVIEW, 2).label(LabelId.VERIFIED, 1);
+    revision.review(in);
+    revision.submit();
+
+    gApi.changes().id(changeId).current().rebase();
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.TRIVIAL_REBASE);
+  }
+
+  private ChangeKind getChangeKind(String changeId) throws Exception {
+    ChangeInfo c = gApi.changes().id(changeId).get(ListChangesOption.CURRENT_REVISION);
+    return c.revisions.get(c.currentRevision).kind;
+  }
+
+  private PushOneCommit.Result createChange(
+      TestRepository<InMemoryRepository> testRepo, TestAccount user) throws Exception {
+    PushOneCommit push = pushFactory.create(user.newIdent(), testRepo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+    return result;
+  }
+
+  private ChangeInfo detailedChange(String changeId) throws Exception {
+    return gApi.changes()
+        .id(changeId)
+        .get(
+            ListChangesOption.DETAILED_LABELS,
+            ListChangesOption.CURRENT_REVISION,
+            ListChangesOption.CURRENT_COMMIT);
+  }
+
+  private PushOneCommit.Result createChange(
+      String subject,
+      String fileName,
+      String content,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user)
+      throws Exception {
+    PushOneCommit push = pushFactory.create(user.newIdent(), testRepo, subject, fileName, content);
+    return push.to("refs/for/master");
+  }
+
+  private void vote(TestAccount user, String changeId, int codeReviewVote, int verifiedVote)
+      throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput in =
+        new ReviewInput()
+            .label(LabelId.CODE_REVIEW, codeReviewVote)
+            .label(LabelId.VERIFIED, verifiedVote);
+    gApi.changes().id(changeId).current().review(in);
+  }
+
+  private void merge(PushOneCommit.Result r) throws Exception {
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 503ab11..afe6fb5 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -28,15 +28,14 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
-import static org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.change.ChangeKindCreator;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -45,26 +44,19 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
 import java.util.EnumSet;
 import java.util.HashMap;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -73,6 +65,7 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ChangeOperations changeOperations;
+  @Inject private ChangeKindCreator changeKindCreator;
 
   @Inject
   @Named("change_kind")
@@ -135,11 +128,11 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, 2, 1);
       vote(user, changeId, 1, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 2, 0, changeKind);
       assertVotes(c, user, 1, 0, changeKind);
@@ -157,11 +150,11 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, -1, 1);
       vote(user, changeId, -2, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 0, 0, changeKind);
       assertVotes(c, user, -2, 0, changeKind);
@@ -179,11 +172,11 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, 2, 1);
       vote(user, changeId, 1, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 2, 0, changeKind);
       assertVotes(c, user, 0, 0, changeKind);
@@ -205,12 +198,12 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, -1, 1);
       vote(user, changeId, -2, -1);
       vote(user2, changeId, 1, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, -1, 0, changeKind);
       assertVotes(c, user, 0, 0, changeKind);
@@ -226,16 +219,16 @@
       u.save();
     }
 
-    String changeId = createChange(TRIVIAL_REBASE);
+    String changeId = changeKindCreator.createChange(TRIVIAL_REBASE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, NO_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, NO_CHANGE);
     assertVotes(c, user, -2, 0, NO_CHANGE);
 
-    updateChange(changeId, TRIVIAL_REBASE);
+    changeKindCreator.updateChange(changeId, TRIVIAL_REBASE, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, TRIVIAL_REBASE);
     assertVotes(c, user, -2, 0, TRIVIAL_REBASE);
@@ -248,7 +241,8 @@
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    String cherryPickChangeId = cherryPick(changeId, TRIVIAL_REBASE);
+    String cherryPickChangeId =
+        changeKindCreator.cherryPick(changeId, TRIVIAL_REBASE, testRepo, admin, project);
     c = detailedChange(cherryPickChangeId);
     assertVotes(c, admin, 2, 0);
     assertVotes(c, user, -2, 0);
@@ -259,7 +253,7 @@
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    cherryPickChangeId = cherryPick(changeId, REWORK);
+    cherryPickChangeId = changeKindCreator.cherryPick(changeId, REWORK, testRepo, admin, project);
     c = detailedChange(cherryPickChangeId);
     assertVotes(c, admin, 0, 0);
     assertVotes(c, user, 0, 0);
@@ -272,16 +266,16 @@
       u.save();
     }
 
-    String changeId = createChange(NO_CODE_CHANGE);
+    String changeId = changeKindCreator.createChange(NO_CODE_CHANGE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, NO_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 0, 1, NO_CHANGE);
     assertVotes(c, user, 0, -1, NO_CHANGE);
 
-    updateChange(changeId, NO_CODE_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 0, 1, NO_CODE_CHANGE);
     assertVotes(c, user, 0, -1, NO_CODE_CHANGE);
@@ -298,16 +292,16 @@
       u.save();
     }
 
-    String changeId = createChange(MERGE_FIRST_PARENT_UPDATE);
+    String changeId = changeKindCreator.createChange(MERGE_FIRST_PARENT_UPDATE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, NO_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, NO_CHANGE);
     assertVotes(c, user, -2, 0, NO_CHANGE);
 
-    updateChange(changeId, MERGE_FIRST_PARENT_UPDATE);
+    changeKindCreator.updateChange(changeId, MERGE_FIRST_PARENT_UPDATE, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, MERGE_FIRST_PARENT_UPDATE);
     assertVotes(c, user, -2, 0, MERGE_FIRST_PARENT_UPDATE);
@@ -322,11 +316,11 @@
       u.save();
     }
 
-    String changeId = createChangeForMergeCommit();
+    String changeId = changeKindCreator.createChangeForMergeCommit(testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateSecondParent(changeId);
+    changeKindCreator.updateSecondParent(changeId, testRepo, admin);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 0, 0, null);
     assertVotes(c, user, 0, 0, null);
@@ -439,7 +433,7 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, 2, 1);
       vote(user, changeId, -2, -1);
 
@@ -450,7 +444,7 @@
       assertVotes(c, admin, 0, 0, null);
       assertVotes(c, user, 0, 0, null);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       c = detailedChange(changeId);
       assertVotes(c, admin, 0, 0, changeKind);
       assertVotes(c, user, 0, 0, changeKind);
@@ -465,16 +459,16 @@
       u.save();
     }
 
-    String changeId = createChange(REWORK);
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, 2, 1);
 
     for (int i = 0; i < 5; i++) {
-      updateChange(changeId, NO_CODE_CHANGE);
+      changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 2, 1, NO_CODE_CHANGE);
     }
 
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, REWORK);
   }
@@ -491,11 +485,11 @@
       u.save();
     }
 
-    String changeId = createChange(REWORK);
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, 2, 1);
-    updateChange(changeId, NO_CODE_CHANGE);
-    updateChange(changeId, NO_CODE_CHANGE);
-    updateChange(changeId, NO_CODE_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
 
     Map<Integer, ObjectId> revisions = new HashMap<>();
     gApi.changes()
@@ -524,24 +518,24 @@
     }
 
     // Vote max score on PS1
-    String changeId = createChange(REWORK);
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, 2, 1);
 
     // Have someone else vote min score on PS2
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     vote(user, changeId, -2, 0);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, REWORK);
     assertVotes(c, user, -2, 0, REWORK);
 
     // No vote changes on PS3
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, REWORK);
     assertVotes(c, user, -2, 0, REWORK);
 
     // Both users revote on PS4
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     vote(admin, changeId, 1, 1);
     vote(user, changeId, 1, 1);
     c = detailedChange(changeId);
@@ -549,7 +543,7 @@
     assertVotes(c, user, 1, 1, REWORK);
 
     // New approvals shouldn't carry through to PS5
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 0, 0, REWORK);
     assertVotes(c, user, 0, 0, REWORK);
@@ -564,10 +558,10 @@
     }
 
     // Vote max score on PS1
-    String changeId = createChange(REWORK);
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, label, 2);
     assertVotes(detailedChange(changeId), admin, label, 2, null);
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     assertVotes(detailedChange(changeId), admin, label, 2, REWORK);
 
     // Delete vote that was copied via sticky approval
@@ -622,209 +616,17 @@
     for (ChangeKind changeKind : changeKinds) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, +2, 1);
       vote(user, changeId, -2, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 0, 0, changeKind);
       assertVotes(c, user, 0, 0, changeKind);
     }
   }
 
-  private String createChange(ChangeKind kind) throws Exception {
-    switch (kind) {
-      case NO_CODE_CHANGE:
-      case REWORK:
-      case TRIVIAL_REBASE:
-      case NO_CHANGE:
-        return createChange().getChangeId();
-      case MERGE_FIRST_PARENT_UPDATE:
-        return createChangeForMergeCommit();
-      default:
-        throw new IllegalStateException("unexpected change kind: " + kind);
-    }
-  }
-
-  private void updateChange(String changeId, ChangeKind changeKind) throws Exception {
-    switch (changeKind) {
-      case NO_CODE_CHANGE:
-        noCodeChange(changeId);
-        return;
-      case REWORK:
-        rework(changeId);
-        return;
-      case TRIVIAL_REBASE:
-        trivialRebase(changeId);
-        return;
-      case MERGE_FIRST_PARENT_UPDATE:
-        updateFirstParent(changeId);
-        return;
-      case NO_CHANGE:
-        noChange(changeId);
-        return;
-      default:
-        assertWithMessage("unexpected change kind: " + changeKind).fail();
-    }
-  }
-
-  private void noCodeChange(String changeId) throws Exception {
-    TestRepository<?>.CommitBuilder commitBuilder =
-        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
-    commitBuilder
-        .message("New subject " + System.nanoTime())
-        .author(admin.newIdent())
-        .committer(new PersonIdent(admin.newIdent(), testRepo.getDate()));
-    commitBuilder.create();
-    GitUtil.pushHead(testRepo, "refs/for/master", false);
-    assertThat(getChangeKind(changeId)).isEqualTo(NO_CODE_CHANGE);
-  }
-
-  private void noChange(String changeId) throws Exception {
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    String commitMessage = change.revisions.get(change.currentRevision).commit.message;
-
-    TestRepository<?>.CommitBuilder commitBuilder =
-        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
-    commitBuilder
-        .message(commitMessage)
-        .author(admin.newIdent())
-        .committer(new PersonIdent(admin.newIdent(), testRepo.getDate()));
-    commitBuilder.create();
-    GitUtil.pushHead(testRepo, "refs/for/master", false);
-    assertThat(getChangeKind(changeId)).isEqualTo(NO_CHANGE);
-  }
-
-  private void rework(String changeId) throws Exception {
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME,
-            "new content " + System.nanoTime(),
-            changeId);
-    push.to("refs/for/master").assertOkStatus();
-    assertThat(getChangeKind(changeId)).isEqualTo(REWORK);
-  }
-
-  private void trivialRebase(String changeId) throws Exception {
-    requestScopeOperations.setApiUser(admin.id());
-    testRepo.reset(projectOperations.project(project).getHead("master"));
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(),
-            testRepo,
-            "Other Change",
-            "a" + System.nanoTime() + ".txt",
-            PushOneCommit.FILE_CONTENT);
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    ReviewInput in = new ReviewInput().label(LabelId.CODE_REVIEW, 2).label(LabelId.VERIFIED, 1);
-    revision.review(in);
-    revision.submit();
-
-    gApi.changes().id(changeId).current().rebase();
-    assertThat(getChangeKind(changeId)).isEqualTo(TRIVIAL_REBASE);
-  }
-
-  private String createChangeForMergeCommit() throws Exception {
-    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
-
-    PushOneCommit.Result parent1 = createChange("parent 1", "p1.txt", "content 1");
-
-    testRepo.reset(initial);
-    PushOneCommit.Result parent2 = createChange("parent 2", "p2.txt", "content 2");
-
-    testRepo.reset(parent1.getCommit());
-
-    PushOneCommit merge = pushFactory.create(admin.newIdent(), testRepo);
-    merge.setParents(ImmutableList.of(parent1.getCommit(), parent2.getCommit()));
-    PushOneCommit.Result result = merge.to("refs/for/master");
-    result.assertOkStatus();
-    return result.getChangeId();
-  }
-
-  private void updateFirstParent(String changeId) throws Exception {
-    ChangeInfo c = detailedChange(changeId);
-    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
-    String parent1 = parents.get(0).commit;
-    String parent2 = parents.get(1).commit;
-    RevCommit commitParent2 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent2));
-
-    testRepo.reset(parent1);
-    PushOneCommit.Result newParent1 = createChange("new parent 1", "p1-1.txt", "content 1-1");
-
-    PushOneCommit merge = pushFactory.create(admin.newIdent(), testRepo, changeId);
-    merge.setParents(ImmutableList.of(newParent1.getCommit(), commitParent2));
-    PushOneCommit.Result result = merge.to("refs/for/master");
-    result.assertOkStatus();
-
-    assertThat(getChangeKind(changeId)).isEqualTo(MERGE_FIRST_PARENT_UPDATE);
-  }
-
-  private void updateSecondParent(String changeId) throws Exception {
-    ChangeInfo c = detailedChange(changeId);
-    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
-    String parent1 = parents.get(0).commit;
-    String parent2 = parents.get(1).commit;
-    RevCommit commitParent1 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent1));
-
-    testRepo.reset(parent2);
-    PushOneCommit.Result newParent2 = createChange("new parent 2", "p2-2.txt", "content 2-2");
-
-    PushOneCommit merge = pushFactory.create(admin.newIdent(), testRepo, changeId);
-    merge.setParents(ImmutableList.of(commitParent1, newParent2.getCommit()));
-    PushOneCommit.Result result = merge.to("refs/for/master");
-    result.assertOkStatus();
-
-    assertThat(getChangeKind(changeId)).isEqualTo(REWORK);
-  }
-
-  private String cherryPick(String changeId, ChangeKind changeKind) throws Exception {
-    switch (changeKind) {
-      case REWORK:
-      case TRIVIAL_REBASE:
-        break;
-      case NO_CODE_CHANGE:
-      case NO_CHANGE:
-      case MERGE_FIRST_PARENT_UPDATE:
-      default:
-        assertWithMessage("unexpected change kind: " + changeKind).fail();
-    }
-
-    testRepo.reset(projectOperations.project(project).getHead("master"));
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                admin.newIdent(),
-                testRepo,
-                PushOneCommit.SUBJECT,
-                "other.txt",
-                "new content " + System.nanoTime())
-            .to("refs/for/master");
-    r.assertOkStatus();
-    vote(admin, r.getChangeId(), 2, 1);
-    merge(r);
-
-    String subject =
-        TRIVIAL_REBASE.equals(changeKind)
-            ? PushOneCommit.SUBJECT
-            : "Reworked change " + System.nanoTime();
-    CherryPickInput in = new CherryPickInput();
-    in.destination = "master";
-    in.message = String.format("%s\n\nChange-Id: %s", subject, changeId);
-    ChangeInfo c = gApi.changes().id(changeId).current().cherryPick(in).get();
-    return c.changeId;
-  }
-
-  private ChangeKind getChangeKind(String changeId) throws Exception {
-    ChangeInfo c = gApi.changes().id(changeId).get(CURRENT_REVISION);
-    return c.revisions.get(c.currentRevision).kind;
-  }
-
   private void vote(TestAccount user, String changeId, String label, int vote) throws Exception {
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).current().review(new ReviewInput().label(label, vote));