Extract replay changes logic into own import step

As a side effect this also extracts the LDAP utility methods into the
AccountUtil class.

Change-Id: I462932bb205aaa9696e31ac1d8927593ed6b1dbd
diff --git a/src/main/java/com/googlesource/gerrit/plugins/importer/AccountUtil.java b/src/main/java/com/googlesource/gerrit/plugins/importer/AccountUtil.java
new file mode 100644
index 0000000..c5c5f42
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/importer/AccountUtil.java
@@ -0,0 +1,83 @@
+//Copyright (C) 2015 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.googlesource.gerrit.plugins.importer;
+
+import com.google.gerrit.common.errors.NoSuchAccountException;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AuthType;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountException;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import java.util.Objects;
+
+@Singleton
+class AccountUtil {
+
+  private final AccountCache accountCache;
+  private final AccountManager accountManager;
+  private final AuthType authType;
+
+  @Inject
+  public AccountUtil(AccountCache accountCache,
+      AccountManager accountManager,
+      AuthConfig authConfig) {
+    this.accountCache = accountCache;
+    this.accountManager = accountManager;
+    this.authType = authConfig.getAuthType();
+  }
+
+  Account.Id resolveUser(AccountInfo acc) throws NoSuchAccountException {
+    AccountState a = accountCache.getByUsername(acc.username);
+    if (a == null) {
+      switch (authType) {
+        case HTTP_LDAP:
+        case CLIENT_SSL_CERT_LDAP:
+        case LDAP:
+          return createAccountByLdap(acc.username);
+        default:
+          throw new NoSuchAccountException(String.format("User %s not found",
+              acc.username));
+      }
+    }
+    if (!Objects.equals(a.getAccount().getPreferredEmail(), acc.email)) {
+      throw new NoSuchAccountException(String.format(
+          "User %s not found: Email mismatch, expected %s but found %s",
+          acc.username, acc.email, a.getAccount().getPreferredEmail()));
+    }
+    return a.getAccount().getId();
+  }
+
+  private Account.Id createAccountByLdap(String user)
+      throws NoSuchAccountException {
+    if (!user.matches(Account.USER_NAME_PATTERN)) {
+      throw new NoSuchAccountException(String.format("User %s not found", user));
+    }
+
+    try {
+      AuthRequest req = AuthRequest.forUser(user);
+      req.setSkipAuthentication(true);
+      return accountManager.authenticate(req).getAccountId();
+    } catch (AccountException e) {
+      throw new NoSuchAccountException(String.format("User %s not found", user));
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/importer/ImportProjectTask.java b/src/main/java/com/googlesource/gerrit/plugins/importer/ImportProjectTask.java
index 7dba0f3..6965bac 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/importer/ImportProjectTask.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/importer/ImportProjectTask.java
@@ -16,85 +16,25 @@
 
 import static java.lang.String.format;
 
-import com.google.common.collect.Ordering;
-import com.google.common.collect.Table;
-import com.google.common.collect.TreeBasedTable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.errors.NoSuchAccountException;
 import com.google.gerrit.extensions.annotations.PluginData;
-import com.google.gerrit.extensions.api.changes.HashtagsInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.NotifyHandling;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AuthType;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountException;
-import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.HashtagsUtil;
-import com.google.gerrit.server.change.PostReview;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.index.ChangeIndexer;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.internal.storage.file.LockFile;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.FS;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.File;
 import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Objects;
 
 class ImportProjectTask implements Runnable {
 
@@ -112,48 +52,21 @@
   private final OpenRepositoryStep openRepoStep;
   private final ConfigureRepositoryStep configRepoStep;
   private final GitFetchStep gitFetchStep;
+  private final ReplayChangesStep.Factory replayChangesFactory;
   private final File lockRoot;
-  private final ReviewDb db;
-  private final AccountCache accountCache;
-  private final AccountManager accountManager;
-  private final AuthType authType;
-  private final CurrentUser currentUser;
-  private final IdentifiedUser.GenericFactory genericUserFactory;
-  private final ChangeControl.GenericFactory changeControlFactory;
-  private final PatchSetInfoFactory patchSetInfoFactory;
-  private final Provider<PostReview> postReview;
-  private final ChangeUpdate.Factory updateFactory;
-  private final ChangeMessagesUtil cmUtil;
-  private final HashtagsUtil hashtagsUtil;
-  private final ChangeIndexer indexer;
 
   private final String fromGerrit;
   private final Project.NameKey name;
   private final String user;
   private final String password;
-  private final RemoteApi api;
   private final StringBuffer messages;
 
-  private Repository repo;
-
   @Inject
   ImportProjectTask(OpenRepositoryStep openRepoStep,
       ConfigureRepositoryStep configRepoStep,
       GitFetchStep gitFetchStep,
+      ReplayChangesStep.Factory replayChangesFactory,
       @PluginData File data,
-      ReviewDb db,
-      AccountCache accountCache,
-      AccountManager accountManager,
-      AuthConfig authConfig,
-      CurrentUser currentUser,
-      IdentifiedUser.GenericFactory genericUserFactory,
-      ChangeControl.GenericFactory changeControlFactory,
-      PatchSetInfoFactory patchSetInfoFactory,
-      Provider<PostReview> postReview,
-      ChangeUpdate.Factory updateFactory,
-      ChangeMessagesUtil cmUtil,
-      HashtagsUtil hashtagsUtil,
-      ChangeIndexer indexer,
       @Assisted("from") String fromGerrit,
       @Assisted Project.NameKey name,
       @Assisted("user") String user,
@@ -162,27 +75,14 @@
     this.openRepoStep = openRepoStep;
     this.configRepoStep = configRepoStep;
     this.gitFetchStep = gitFetchStep;
+    this.replayChangesFactory = replayChangesFactory;
     this.lockRoot = data;
-    this.db = db;
-    this.accountCache = accountCache;
-    this.accountManager = accountManager;
-    this.authType = authConfig.getAuthType();
-    this.currentUser = currentUser;
-    this.genericUserFactory = genericUserFactory;
-    this.changeControlFactory = changeControlFactory;
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.postReview = postReview;
-    this.updateFactory = updateFactory;
-    this.cmUtil = cmUtil;
-    this.hashtagsUtil = hashtagsUtil;
-    this.indexer = indexer;
 
     this.fromGerrit = fromGerrit;
     this.name = name;
     this.user = user;
     this.password = password;
     this.messages = messages;
-    this.api = new RemoteApi(fromGerrit, user, password);
   }
 
   @Override
@@ -193,7 +93,7 @@
     }
 
     try {
-      repo = openRepoStep.open(name, messages);
+      Repository repo = openRepoStep.open(name, messages);
       if (repo == null) {
         return;
       }
@@ -201,7 +101,8 @@
       try {
         configRepoStep.configure(repo, name, fromGerrit);
         gitFetchStep.fetch(user, password, repo, name, messages);
-        replayChanges();
+        replayChangesFactory.create(fromGerrit, user, password, repo, name)
+            .replay();
       } catch (IOException | GitAPIException | OrmException
           | NoSuchAccountException | NoSuchChangeException | RestApiException
           | ValidationException e) {
@@ -237,343 +138,4 @@
       return null;
     }
   }
-
-  private void replayChanges() throws IOException, OrmException,
-      NoSuchAccountException, NoSuchChangeException, RestApiException,
-      ValidationException {
-    List<ChangeInfo> changes = api.queryChanges(name.get());
-    RevWalk rw = new RevWalk(repo);
-    try {
-      for (ChangeInfo c : changes) {
-        replayChange(rw, c);
-      }
-    } finally {
-      rw.release();
-    }
-  }
-
-  private void replayChange(RevWalk rw, ChangeInfo c)
-      throws IOException, OrmException, NoSuchAccountException,
-      NoSuchChangeException, RestApiException, ValidationException {
-    Change change = createChange(c);
-    replayRevisions(rw, change, c);
-    db.changes().insert(Collections.singleton(change));
-
-    replayInlineComments(change, c);
-    replayMessages(change, c);
-    addApprovals(change, c);
-    addHashtags(change, c);
-
-    insertLinkToOriginalChange(change, c);
-
-    indexer.index(db, change);
-  }
-
-  private Change createChange(ChangeInfo c) throws OrmException,
-      NoSuchAccountException {
-    Change.Id changeId = new Change.Id(db.nextChangeId());
-
-    Change change =
-        new Change(new Change.Key(c.changeId), changeId, resolveUser(c.owner),
-            new Branch.NameKey(new Project.NameKey(c.project),
-            fullName(c.branch)), c.created);
-    change.setStatus(Change.Status.forChangeStatus(c.status));
-    change.setTopic(c.topic);
-    return change;
-  }
-
-
-  private static String fullName(String branch) {
-    if (branch.startsWith(Constants.R_HEADS)) {
-      return branch;
-    } else {
-      return Constants.R_HEADS + branch;
-    }
-  }
-
-  /**
-   * @return the current patch set for the given change
-   */
-  private void replayRevisions(RevWalk rw, Change change,
-      ChangeInfo c) throws IOException, OrmException, NoSuchAccountException {
-    List<RevisionInfo> revisions = new ArrayList<>(c.revisions.values());
-    sortRevisionInfoByNumber(revisions);
-    List<PatchSet> patchSets = new ArrayList<>();
-
-    db.changes().beginTransaction(change.getId());
-    try {
-      for (RevisionInfo r : revisions) {
-        String origRef = r.ref;
-        ObjectId id = repo.resolve(origRef);
-        if (id == null) {
-          // already replayed?
-          continue;
-        }
-        RevCommit commit = rw.parseCommit(id);
-
-        PatchSet ps = new PatchSet(new PatchSet.Id(change.getId(), r._number));
-        patchSets.add(ps);
-
-        ps.setUploader(resolveUser(r.uploader));
-        ps.setCreatedOn(r.created);
-        ps.setRevision(new RevId(commit.name()));
-        ps.setDraft(r.draft != null && r.draft);
-
-        PatchSetInfo info = patchSetInfoFactory.get(commit, ps.getId());
-        if (c.currentRevision.equals(info.getRevId())) {
-          change.setCurrentPatchSet(info);
-        }
-
-        ChangeUtil.insertAncestors(db, ps.getId(), commit);
-
-        renameRef(repo, origRef, ps);
-      }
-
-      db.patchSets().insert(patchSets);
-      db.commit();
-    } finally {
-      db.rollback();
-    }
-  }
-
-  private static void sortRevisionInfoByNumber(List<RevisionInfo> list) {
-    Collections.sort(list, new Comparator<RevisionInfo>() {
-      @Override
-      public int compare(RevisionInfo a, RevisionInfo b) {
-        return a._number - b._number;
-      }
-    });
-  }
-
-  private void replayInlineComments(Change change, ChangeInfo c) throws OrmException,
-      RestApiException, IOException, NoSuchChangeException,
-      NoSuchAccountException {
-    for (PatchSet ps : db.patchSets().byChange(change.getId())) {
-      Iterable<CommentInfo> comments = api.getComments(
-          c._number, ps.getRevision().get());
-
-      Table<Timestamp, Account.Id, List<CommentInfo>> t = TreeBasedTable.create(
-          Ordering.natural(), new Comparator<Account.Id>() {
-            @Override
-            public int compare(Account.Id a1, Account.Id a2) {
-              return a1.get() - a2.get();
-            }}
-          );
-
-      for (CommentInfo comment : comments) {
-        Account.Id id  = resolveUser(comment.author);
-        List<CommentInfo> ci = t.get(comment.updated, id);
-        if (ci == null) {
-          ci = new ArrayList<>();
-          t.put(comment.updated, id, ci);
-        }
-        ci.add(comment);
-      }
-
-      for (Timestamp ts : t.rowKeySet()) {
-        for (Map.Entry<Account.Id, List<CommentInfo>> e : t.row(ts).entrySet()) {
-          postComments(change, ps, e.getValue(), e.getKey(), ts);
-        }
-      }
-    }
-  }
-
-  private void postComments(Change change, PatchSet ps,
-      List<CommentInfo> comments, Account.Id author, Timestamp ts)
-      throws RestApiException, OrmException, IOException, NoSuchChangeException {
-    ReviewInput input = new ReviewInput();
-    input.notify = NotifyHandling.NONE;
-    input.comments = new HashMap<>();
-
-    for (CommentInfo comment : comments) {
-      if (!input.comments.containsKey(comment.path)) {
-        input.comments.put(comment.path,
-            new ArrayList<ReviewInput.CommentInput>());
-      }
-
-      ReviewInput.CommentInput commentInput = new ReviewInput.CommentInput();
-      commentInput.id = comment.id;
-      commentInput.inReplyTo = comment.inReplyTo;
-      commentInput.line = comment.line;
-      commentInput.message = comment.message;
-      commentInput.path = comment.path;
-      commentInput.range = comment.range;
-      commentInput.side = comment.side;
-      commentInput.updated = comment.updated;
-
-      input.comments.get(comment.path).add(commentInput);
-    }
-
-    postReview.get().apply(
-        new RevisionResource(new ChangeResource(control(change, author)), ps),
-        input, ts);
-  }
-
-  private void renameRef(Repository repo, String origRef, PatchSet ps)
-      throws IOException {
-    String ref = ps.getId().toRefName();
-    if (ref.equals(origRef)) {
-      return;
-    }
-
-    createRef(repo, ps);
-    deleteRef(repo, ps, origRef);
-  }
-
-  private void createRef(Repository repo, PatchSet ps) throws IOException {
-    String ref = ps.getId().toRefName();
-    RefUpdate ru = repo.updateRef(ref);
-    ru.setForceUpdate(true);
-    ru.setExpectedOldObjectId(ObjectId.zeroId());
-    ru.setNewObjectId(ObjectId.fromString(ps.getRevision().get()));
-    RefUpdate.Result result = ru.update();
-    switch (result) {
-      case NEW:
-      case FORCED:
-      case FAST_FORWARD:
-        return;
-      default:
-        throw new IOException(String.format("Failed to create ref %s", ref));
-    }
-  }
-
-  private void deleteRef(Repository repo, PatchSet ps, String ref)
-      throws IOException {
-    RefUpdate ru = repo.updateRef(ref);
-    ru.setForceUpdate(true);
-    ru.setExpectedOldObjectId(ObjectId.fromString(ps.getRevision().get()));
-    ru.setNewObjectId(ObjectId.zeroId());
-    RefUpdate.Result result = ru.update();
-    switch (result) {
-      case FORCED:
-        return;
-      default:
-        throw new IOException(String.format("Failed to delete ref %s", ref));
-    }
-  }
-
-  private Account.Id resolveUser(AccountInfo acc) throws NoSuchAccountException {
-    AccountState a = accountCache.getByUsername(acc.username);
-    if (a == null) {
-      switch (authType) {
-        case HTTP_LDAP:
-        case CLIENT_SSL_CERT_LDAP:
-        case LDAP:
-          return createAccountByLdap(acc.username);
-        default:
-          throw new NoSuchAccountException(String.format("User %s not found",
-              acc.username));
-      }
-    }
-    if (!Objects.equals(a.getAccount().getPreferredEmail(), acc.email)) {
-      throw new NoSuchAccountException(String.format(
-          "User %s not found: Email mismatch, expected %s but found %s",
-          acc.username, acc.email, a.getAccount().getPreferredEmail()));
-    }
-    return a.getAccount().getId();
-  }
-
-  private Account.Id createAccountByLdap(String user)
-      throws NoSuchAccountException {
-    if (!user.matches(Account.USER_NAME_PATTERN)) {
-      throw new NoSuchAccountException(String.format("User %s not found", user));
-    }
-
-    try {
-      AuthRequest req = AuthRequest.forUser(user);
-      req.setSkipAuthentication(true);
-      return accountManager.authenticate(req).getAccountId();
-    } catch (AccountException e) {
-      throw new NoSuchAccountException(String.format("User %s not found", user));
-    }
-  }
-
-  private void replayMessages(Change change, ChangeInfo c)
-      throws IOException, NoSuchChangeException, OrmException,
-      NoSuchAccountException {
-    for (ChangeMessageInfo msg : c.messages) {
-      Account.Id userId = resolveUser(msg.author);
-      Timestamp ts = msg.date;
-      ChangeUpdate update = updateFactory.create(control(change, userId), ts);
-      ChangeMessage cmsg =
-          new ChangeMessage(new ChangeMessage.Key(change.getId(), msg.id),
-              userId, ts, new PatchSet.Id(change.getId(), msg._revisionNumber));
-      cmsg.setMessage(msg.message);
-      cmUtil.addChangeMessage(db, update, cmsg);
-      update.commit();
-    }
-  }
-
-  private void addApprovals(Change change, ChangeInfo c)
-      throws OrmException, NoSuchChangeException, IOException,
-      NoSuchAccountException {
-    List<PatchSetApproval> approvals = new ArrayList<>();
-    for (Entry<String, LabelInfo> e : c.labels.entrySet()) {
-      String labelName = e.getKey();
-      LabelInfo label = e.getValue();
-      if (label.all != null) {
-        for (ApprovalInfo a : label.all) {
-          Account.Id user = resolveUser(a);
-          ChangeControl ctrl = control(change, a);
-          LabelType labelType = ctrl.getLabelTypes().byLabel(labelName);
-          approvals.add(new PatchSetApproval(
-              new PatchSetApproval.Key(change.currentPatchSetId(), user,
-                  labelType.getLabelId()), a.value.shortValue(), a.date));
-          ChangeUpdate update = updateFactory.create(ctrl);
-          update.putApproval(labelName, a.value.shortValue());
-          update.commit();
-        }
-      }
-    }
-    db.patchSetApprovals().upsert(approvals);
-  }
-
-  private void addHashtags(Change change, ChangeInfo c) throws AuthException,
-      IOException, ValidationException, OrmException, NoSuchChangeException {
-    HashtagsInput input = new HashtagsInput();
-    input.add = new HashSet<>(c.hashtags);
-    hashtagsUtil.setHashtags(control(change, c.owner), input, false, false);
-  }
-
-  private void insertLinkToOriginalChange(Change change,
-      ChangeInfo c) throws NoSuchChangeException, OrmException, IOException {
-    insertMessage(change, "Imported from " + changeUrl(c));
-  }
-
-  private String changeUrl(ChangeInfo c) {
-    StringBuilder url = new StringBuilder();
-    url.append(ensureSlash(fromGerrit)).append(c._number);
-    return url.toString();
-  }
-
-  private void insertMessage(Change change, String message)
-      throws NoSuchChangeException, OrmException, IOException {
-    Account.Id userId = ((IdentifiedUser) currentUser).getAccountId();
-    ChangeUpdate update = updateFactory.create(control(change, userId));
-    ChangeMessage cmsg =
-        new ChangeMessage(new ChangeMessage.Key(change.getId(),
-            ChangeUtil.messageUUID(db)), userId, TimeUtil.nowTs(),
-            change.currentPatchSetId());
-    cmsg.setMessage(message);
-    cmUtil.addChangeMessage(db, update, cmsg);
-    update.commit();
-  }
-
-  private ChangeControl control(Change change, AccountInfo acc)
-      throws NoSuchChangeException {
-    return control(change, new Account.Id(acc._accountId));
-  }
-
-  private ChangeControl control(Change change, Account.Id id)
-      throws NoSuchChangeException {
-    return changeControlFactory.controlFor(change,
-        genericUserFactory.create(id));
-  }
-
-  private static String ensureSlash(String in) {
-    if (in != null && !in.endsWith("/")) {
-      return in + "/";
-    }
-    return in;
-  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/importer/Module.java b/src/main/java/com/googlesource/gerrit/plugins/importer/Module.java
index 04f7bf8..4ffeabe 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/importer/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/importer/Module.java
@@ -43,5 +43,7 @@
     bind(OpenRepositoryStep.class);
     bind(ConfigureRepositoryStep.class);
     bind(GitFetchStep.class);
+    bind(AccountUtil.class);
+    install(new FactoryModuleBuilder().build(ReplayChangesStep.Factory.class));
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/importer/ReplayChangesStep.java b/src/main/java/com/googlesource/gerrit/plugins/importer/ReplayChangesStep.java
new file mode 100644
index 0000000..bd7c099
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/importer/ReplayChangesStep.java
@@ -0,0 +1,442 @@
+//Copyright (C) 2015 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.googlesource.gerrit.plugins.importer;
+
+import com.google.common.collect.Ordering;
+import com.google.common.collect.Table;
+import com.google.common.collect.TreeBasedTable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.errors.NoSuchAccountException;
+import com.google.gerrit.extensions.api.changes.HashtagsInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.HashtagsUtil;
+import com.google.gerrit.server.change.PostReview;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.index.ChangeIndexer;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+class ReplayChangesStep {
+
+  interface Factory {
+    ReplayChangesStep create(
+        @Assisted("from") String fromGerrit,
+        @Assisted("user") String user,
+        @Assisted("password") String password,
+        Repository repo,
+        Project.NameKey name);
+  }
+
+  private final AccountUtil accountUtil;
+  private final ReviewDb db;
+  private final ChangeIndexer indexer;
+  private final PatchSetInfoFactory patchSetInfoFactory;
+  private final Provider<PostReview> postReview;
+  private final ChangeUpdate.Factory updateFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final HashtagsUtil hashtagsUtil;
+  private final CurrentUser currentUser;
+  private final IdentifiedUser.GenericFactory genericUserFactory;
+  private final ChangeControl.GenericFactory changeControlFactory;
+  private final String fromGerrit;
+  private final RemoteApi api;
+  private final Repository repo;
+  private final Project.NameKey name;
+
+  @Inject
+  ReplayChangesStep(
+      AccountUtil accountUtil,
+      ReviewDb db,
+      ChangeIndexer indexer,
+      PatchSetInfoFactory patchSetInfoFactory,
+      Provider<PostReview> postReview,
+      ChangeUpdate.Factory updateFactory,
+      ChangeMessagesUtil cmUtil,
+      HashtagsUtil hashtagsUtil,
+      CurrentUser currentUser,
+      IdentifiedUser.GenericFactory genericUserFactory,
+      ChangeControl.GenericFactory changeControlFactory,
+      @Assisted("from") String fromGerrit,
+      @Assisted("user") String user,
+      @Assisted("password") String password,
+      @Assisted Repository repo,
+      @Assisted Project.NameKey name) {
+    this.accountUtil = accountUtil;
+    this.db = db;
+    this.indexer = indexer;
+    this.patchSetInfoFactory = patchSetInfoFactory;
+    this.postReview = postReview;
+    this.updateFactory = updateFactory;
+    this.cmUtil = cmUtil;
+    this.hashtagsUtil = hashtagsUtil;
+    this.currentUser = currentUser;
+    this.genericUserFactory = genericUserFactory;
+    this.changeControlFactory = changeControlFactory;
+    this.fromGerrit = fromGerrit;
+    this.api = new RemoteApi(fromGerrit, user, password);
+    this.repo = repo;
+    this.name = name;
+  }
+
+  void replay() throws IOException, OrmException,
+      NoSuchAccountException, NoSuchChangeException, RestApiException,
+      ValidationException {
+    List<ChangeInfo> changes = api.queryChanges(name.get());
+    RevWalk rw = new RevWalk(repo);
+    try {
+      for (ChangeInfo c : changes) {
+        replayChange(rw, c);
+      }
+    } finally {
+      rw.release();
+    }
+  }
+
+  private void replayChange(RevWalk rw, ChangeInfo c)
+      throws IOException, OrmException, NoSuchAccountException,
+      NoSuchChangeException, RestApiException, ValidationException {
+    Change change = createChange(c);
+    replayRevisions(rw, change, c);
+    db.changes().insert(Collections.singleton(change));
+
+    replayInlineComments(change, c);
+    replayMessages(change, c);
+    addApprovals(change, c);
+    addHashtags(change, c);
+
+    insertLinkToOriginalChange(change, c);
+
+    indexer.index(db, change);
+  }
+
+  private Change createChange(ChangeInfo c) throws OrmException,
+      NoSuchAccountException {
+    Change.Id changeId = new Change.Id(db.nextChangeId());
+
+    Change change =
+        new Change(new Change.Key(c.changeId), changeId, accountUtil.resolveUser(c.owner),
+            new Branch.NameKey(new Project.NameKey(c.project),
+            fullName(c.branch)), c.created);
+    change.setStatus(Change.Status.forChangeStatus(c.status));
+    change.setTopic(c.topic);
+    return change;
+  }
+
+  private static String fullName(String branch) {
+    if (branch.startsWith(Constants.R_HEADS)) {
+      return branch;
+    } else {
+      return Constants.R_HEADS + branch;
+    }
+  }
+
+  private void replayRevisions(RevWalk rw, Change change,
+      ChangeInfo c) throws IOException, OrmException, NoSuchAccountException {
+    List<RevisionInfo> revisions = new ArrayList<>(c.revisions.values());
+    sortRevisionInfoByNumber(revisions);
+    List<PatchSet> patchSets = new ArrayList<>();
+
+    db.changes().beginTransaction(change.getId());
+    try {
+      for (RevisionInfo r : revisions) {
+        String origRef = r.ref;
+        ObjectId id = repo.resolve(origRef);
+        if (id == null) {
+          // already replayed?
+          continue;
+        }
+        RevCommit commit = rw.parseCommit(id);
+
+        PatchSet ps = new PatchSet(new PatchSet.Id(change.getId(), r._number));
+        patchSets.add(ps);
+
+        ps.setUploader(accountUtil.resolveUser(r.uploader));
+        ps.setCreatedOn(r.created);
+        ps.setRevision(new RevId(commit.name()));
+        ps.setDraft(r.draft != null && r.draft);
+
+        PatchSetInfo info = patchSetInfoFactory.get(commit, ps.getId());
+        if (c.currentRevision.equals(info.getRevId())) {
+          change.setCurrentPatchSet(info);
+        }
+
+        ChangeUtil.insertAncestors(db, ps.getId(), commit);
+
+        renameRef(repo, origRef, ps);
+      }
+
+      db.patchSets().insert(patchSets);
+      db.commit();
+    } finally {
+      db.rollback();
+    }
+  }
+
+  private static void sortRevisionInfoByNumber(List<RevisionInfo> list) {
+    Collections.sort(list, new Comparator<RevisionInfo>() {
+      @Override
+      public int compare(RevisionInfo a, RevisionInfo b) {
+        return a._number - b._number;
+      }
+    });
+  }
+
+  private void replayInlineComments(Change change, ChangeInfo c) throws OrmException,
+      RestApiException, IOException, NoSuchChangeException,
+      NoSuchAccountException {
+    for (PatchSet ps : db.patchSets().byChange(change.getId())) {
+      Iterable<CommentInfo> comments = api.getComments(
+          c._number, ps.getRevision().get());
+
+      Table<Timestamp, Account.Id, List<CommentInfo>> t = TreeBasedTable.create(
+          Ordering.natural(), new Comparator<Account.Id>() {
+            @Override
+            public int compare(Account.Id a1, Account.Id a2) {
+              return a1.get() - a2.get();
+            }}
+          );
+
+      for (CommentInfo comment : comments) {
+        Account.Id id  = accountUtil.resolveUser(comment.author);
+        List<CommentInfo> ci = t.get(comment.updated, id);
+        if (ci == null) {
+          ci = new ArrayList<>();
+          t.put(comment.updated, id, ci);
+        }
+        ci.add(comment);
+      }
+
+      for (Timestamp ts : t.rowKeySet()) {
+        for (Map.Entry<Account.Id, List<CommentInfo>> e : t.row(ts).entrySet()) {
+          postComments(change, ps, e.getValue(), e.getKey(), ts);
+        }
+      }
+    }
+  }
+
+  private void postComments(Change change, PatchSet ps,
+      List<CommentInfo> comments, Account.Id author, Timestamp ts)
+      throws RestApiException, OrmException, IOException, NoSuchChangeException {
+    ReviewInput input = new ReviewInput();
+    input.notify = NotifyHandling.NONE;
+    input.comments = new HashMap<>();
+
+    for (CommentInfo comment : comments) {
+      if (!input.comments.containsKey(comment.path)) {
+        input.comments.put(comment.path,
+            new ArrayList<ReviewInput.CommentInput>());
+      }
+
+      ReviewInput.CommentInput commentInput = new ReviewInput.CommentInput();
+      commentInput.id = comment.id;
+      commentInput.inReplyTo = comment.inReplyTo;
+      commentInput.line = comment.line;
+      commentInput.message = comment.message;
+      commentInput.path = comment.path;
+      commentInput.range = comment.range;
+      commentInput.side = comment.side;
+      commentInput.updated = comment.updated;
+
+      input.comments.get(comment.path).add(commentInput);
+    }
+
+    postReview.get().apply(
+        new RevisionResource(new ChangeResource(control(change, author)), ps),
+        input, ts);
+  }
+
+  private void renameRef(Repository repo, String origRef, PatchSet ps)
+      throws IOException {
+    String ref = ps.getId().toRefName();
+    if (ref.equals(origRef)) {
+      return;
+    }
+
+    createRef(repo, ps);
+    deleteRef(repo, ps, origRef);
+  }
+
+  private void createRef(Repository repo, PatchSet ps) throws IOException {
+    String ref = ps.getId().toRefName();
+    RefUpdate ru = repo.updateRef(ref);
+    ru.setForceUpdate(true);
+    ru.setExpectedOldObjectId(ObjectId.zeroId());
+    ru.setNewObjectId(ObjectId.fromString(ps.getRevision().get()));
+    RefUpdate.Result result = ru.update();
+    switch (result) {
+      case NEW:
+      case FORCED:
+      case FAST_FORWARD:
+        return;
+      default:
+        throw new IOException(String.format("Failed to create ref %s", ref));
+    }
+  }
+
+  private void deleteRef(Repository repo, PatchSet ps, String ref)
+      throws IOException {
+    RefUpdate ru = repo.updateRef(ref);
+    ru.setForceUpdate(true);
+    ru.setExpectedOldObjectId(ObjectId.fromString(ps.getRevision().get()));
+    ru.setNewObjectId(ObjectId.zeroId());
+    RefUpdate.Result result = ru.update();
+    switch (result) {
+      case FORCED:
+        return;
+      default:
+        throw new IOException(String.format("Failed to delete ref %s", ref));
+    }
+  }
+
+  private void replayMessages(Change change, ChangeInfo c)
+      throws IOException, NoSuchChangeException, OrmException,
+      NoSuchAccountException {
+    for (ChangeMessageInfo msg : c.messages) {
+      Account.Id userId = accountUtil.resolveUser(msg.author);
+      Timestamp ts = msg.date;
+      ChangeUpdate update = updateFactory.create(control(change, userId), ts);
+      ChangeMessage cmsg =
+          new ChangeMessage(new ChangeMessage.Key(change.getId(), msg.id),
+              userId, ts, new PatchSet.Id(change.getId(), msg._revisionNumber));
+      cmsg.setMessage(msg.message);
+      cmUtil.addChangeMessage(db, update, cmsg);
+      update.commit();
+    }
+  }
+
+  private void addApprovals(Change change, ChangeInfo c)
+      throws OrmException, NoSuchChangeException, IOException,
+      NoSuchAccountException {
+    List<PatchSetApproval> approvals = new ArrayList<>();
+    for (Entry<String, LabelInfo> e : c.labels.entrySet()) {
+      String labelName = e.getKey();
+      LabelInfo label = e.getValue();
+      if (label.all != null) {
+        for (ApprovalInfo a : label.all) {
+          Account.Id user = accountUtil.resolveUser(a);
+          ChangeControl ctrl = control(change, a);
+          LabelType labelType = ctrl.getLabelTypes().byLabel(labelName);
+          approvals.add(new PatchSetApproval(
+              new PatchSetApproval.Key(change.currentPatchSetId(), user,
+                  labelType.getLabelId()), a.value.shortValue(), a.date));
+          ChangeUpdate update = updateFactory.create(ctrl);
+          update.putApproval(labelName, a.value.shortValue());
+          update.commit();
+        }
+      }
+    }
+    db.patchSetApprovals().upsert(approvals);
+  }
+
+  private void addHashtags(Change change, ChangeInfo c) throws AuthException,
+      IOException, ValidationException, OrmException, NoSuchChangeException {
+    HashtagsInput input = new HashtagsInput();
+    input.add = new HashSet<>(c.hashtags);
+    hashtagsUtil.setHashtags(control(change, c.owner), input, false, false);
+  }
+
+  private void insertLinkToOriginalChange(Change change,
+      ChangeInfo c) throws NoSuchChangeException, OrmException, IOException {
+    insertMessage(change, "Imported from " + changeUrl(c));
+  }
+
+  private String changeUrl(ChangeInfo c) {
+    StringBuilder url = new StringBuilder();
+    url.append(ensureSlash(fromGerrit)).append(c._number);
+    return url.toString();
+  }
+
+  private void insertMessage(Change change, String message)
+      throws NoSuchChangeException, OrmException, IOException {
+    Account.Id userId = ((IdentifiedUser) currentUser).getAccountId();
+    ChangeUpdate update = updateFactory.create(control(change, userId));
+    ChangeMessage cmsg =
+        new ChangeMessage(new ChangeMessage.Key(change.getId(),
+            ChangeUtil.messageUUID(db)), userId, TimeUtil.nowTs(),
+            change.currentPatchSetId());
+    cmsg.setMessage(message);
+    cmUtil.addChangeMessage(db, update, cmsg);
+    update.commit();
+  }
+
+  private ChangeControl control(Change change, AccountInfo acc)
+      throws NoSuchChangeException {
+    return control(change, new Account.Id(acc._accountId));
+  }
+
+  private ChangeControl control(Change change, Account.Id id)
+      throws NoSuchChangeException {
+    return changeControlFactory.controlFor(change,
+        genericUserFactory.create(id));
+  }
+
+  private static String ensureSlash(String in) {
+    if (in != null && !in.endsWith("/")) {
+      return in + "/";
+    }
+    return in;
+  }
+}