Add a method to submit the batch

This provides a new SSH command to submit a batch. Submitting a batch
updates the destination ref(s) and closes the changes.

Change-Id: I7a9ac29316ae3b120ab2154146a08e2bef6f0f50
diff --git a/src/main/java/com/googlesource/gerrit/plugins/batch/Batch.java b/src/main/java/com/googlesource/gerrit/plugins/batch/Batch.java
index b2058ce..413e724 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/batch/Batch.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/batch/Batch.java
@@ -38,6 +38,10 @@
       number = psId.getParentKey().get();
       patchSet = psId.get();
     }
+
+    public PatchSet.Id toPatchSetId() {
+      return new PatchSet.Id(new com.google.gerrit.reviewdb.client.Change.Id(number), patchSet);
+    }
   }
 
   public class Destination {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/batch/BatchSubmitter.java b/src/main/java/com/googlesource/gerrit/plugins/batch/BatchSubmitter.java
new file mode 100644
index 0000000..7697dab
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/batch/BatchSubmitter.java
@@ -0,0 +1,184 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.batch;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergedByPushOp;
+import com.google.gerrit.server.notedb.ChangeNotes;
+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.NoSuchProjectException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.RefUpdater;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.batch.exception.NoSuchBatchException;
+import java.io.IOException;
+import java.util.Collection;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class BatchSubmitter {
+  private static final Logger log = LoggerFactory.getLogger(BatchSubmitter.class);
+
+  protected final ReviewDb db;
+  protected final GitRepositoryManager repoManager;
+  protected final RefUpdater refUpdater;
+  protected final RequestScopePropagator requestScopePropagator;
+  protected final BatchUpdate.Factory batchUpdateFactory;
+  protected final MergedByPushOp.Factory mergedByPushOpFactory;
+  protected final IdentifiedUser user;
+  protected final ChangeNotes.Factory notesFactory;
+  protected final PermissionBackend permissionBackend;
+  protected final PatchSetUtil psUtil;
+  protected final BatchStore store;
+  protected final BatchRemover remover;
+
+  @Inject
+  BatchSubmitter(
+      ReviewDb db,
+      GitRepositoryManager repoManager,
+      RefUpdater refUpdater,
+      RequestScopePropagator requestScopePropagator,
+      BatchUpdate.Factory batchUpdateFactory,
+      MergedByPushOp.Factory mergedByPushOpFactory,
+      IdentifiedUser user,
+      ChangeNotes.Factory notesFactory,
+      PermissionBackend permissionBackend,
+      PatchSetUtil psUtil,
+      BatchStore store,
+      BatchRemover remover) {
+    this.db = db;
+    this.repoManager = repoManager;
+    this.refUpdater = refUpdater;
+    this.requestScopePropagator = requestScopePropagator;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.mergedByPushOpFactory = mergedByPushOpFactory;
+    this.user = user;
+    this.notesFactory = notesFactory;
+    this.permissionBackend = permissionBackend;
+    this.psUtil = psUtil;
+    this.store = store;
+    this.remover = remover;
+  }
+
+  public Batch submit(String id)
+      throws IOException, IllegalStateException, NoSuchBatchException, NoSuchProjectException,
+          OrmException, RestApiException, UpdateException, PermissionBackendException {
+    Batch batch = store.read(id);
+    if (batch.state == Batch.State.OPEN) {
+      throw new IllegalStateException("Cannot submit batch " + id + " in state " + batch.state);
+    }
+    ensureCanSubmit(batch);
+    submit(batch);
+    remover.remove(batch);
+    return batch;
+  }
+
+  private void ensureCanSubmit(Batch batch) throws AuthException, PermissionBackendException {
+    for (Batch.Destination dest : batch.listDestinations()) {
+      ensureCanSubmit(dest);
+    }
+  }
+
+  private void ensureCanSubmit(Batch.Destination dest)
+      throws AuthException, PermissionBackendException {
+    PermissionBackend.ForProject permissions =
+        permissionBackend.user(user).project(new Project.NameKey(dest.project));
+    permissions.ref(dest.ref).check(RefPermission.FORCE_UPDATE);
+  }
+
+  private void submit(Batch batch)
+      throws IOException, OrmException, NoSuchProjectException, RepositoryNotFoundException,
+          RestApiException, UpdateException, PermissionBackendException {
+    for (Batch.Destination dest : batch.listDestinations()) {
+      updateRef(dest);
+      if (dest.changes != null) {
+        closeChanges(dest.changes);
+      }
+    }
+  }
+
+  private void updateRef(Batch.Destination dest)
+      throws IOException, NoSuchProjectException, RepositoryNotFoundException {
+    Project.NameKey project = new Project.NameKey(dest.project);
+    Branch.NameKey branch = new Branch.NameKey(project, dest.ref);
+    refUpdater.forceUpdate(branch, ObjectId.fromString(dest.sha1));
+  }
+
+  private void closeChanges(Collection<Batch.Change> changes)
+      throws IOException, OrmException, RepositoryNotFoundException, RestApiException,
+          UpdateException, PermissionBackendException {
+    for (Batch.Change change : changes) {
+      closeChange(change.toPatchSetId());
+    }
+  }
+
+  private void closeChange(PatchSet.Id psId)
+      throws IOException, OrmException, RepositoryNotFoundException, RestApiException,
+          UpdateException, PermissionBackendException {
+    ChangeNotes changeNotes = notesFactory.createChecked(psId.getParentKey());
+    permissionBackend.user(user).database(db).change(changeNotes).check(ChangePermission.READ);
+    Change change = changeNotes.getChange();
+    PatchSet ps = psUtil.get(db, changeNotes, psId);
+    if (change == null || ps == null) {
+      log.error("" + psId + " is missing");
+      return;
+    }
+
+    if (change.getStatus() == Change.Status.MERGED
+        || change.getStatus() == Change.Status.ABANDONED) {
+      return;
+    }
+    Branch.NameKey destination = change.getDest();
+    Project.NameKey project = destination.getParentKey();
+
+    try (Repository repo = repoManager.openRepository(project);
+        BatchUpdate bu = batchUpdateFactory.create(db, project, user, TimeUtil.nowTs());
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk walk = new RevWalk(reader)) {
+      bu.setRepository(repo, walk, ins).updateChangesInParallel();
+      bu.setRequestId(RequestId.forChange(change));
+      bu.setRefLogMessage("merged (batch submit)");
+
+      bu.addOp(
+          psId.getParentKey(),
+          mergedByPushOpFactory.create(requestScopePropagator, psId, destination.get()));
+      bu.execute();
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/batch/ssh/SshModule.java b/src/main/java/com/googlesource/gerrit/plugins/batch/ssh/SshModule.java
index b80cf29..da94a64 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/batch/ssh/SshModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/batch/ssh/SshModule.java
@@ -21,5 +21,6 @@
     command(MergeChangeCommand.class);
     command(DeleteCommand.class);
     command(ListCommand.class);
+    command(SubmitCommand.class);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/batch/ssh/SubmitCommand.java b/src/main/java/com/googlesource/gerrit/plugins/batch/ssh/SubmitCommand.java
new file mode 100644
index 0000000..1737818
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/batch/ssh/SubmitCommand.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.batch.ssh;
+
+import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.batch.Batch;
+import com.googlesource.gerrit.plugins.batch.BatchSubmitter;
+import com.googlesource.gerrit.plugins.batch.exception.NoSuchBatchException;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+@CommandMetaData(
+    name = "submit",
+    description = "Submit the batch updates to the destination branches")
+public class SubmitCommand extends SshCommand {
+  @Option(name = "--force", required = true, usage = "force push the batch updates")
+  protected boolean force;
+
+  @Argument(metaVar = "BATCH-ID", usage = "id of the batch to submit")
+  protected String batchId;
+
+  @Inject protected BatchSubmitter impl;
+
+  @Override
+  public void run() throws Exception {
+    parseCommandLine();
+    try {
+      Batch batch = impl.submit(batchId);
+      out.write((OutputFormat.JSON.newGson().toJson(batch) + "\n").getBytes(ENC));
+    } catch (NoSuchBatchException | IllegalStateException | PermissionBackendException e) {
+      throw new UnloggedFailure(1, e.getMessage());
+    }
+    out.flush();
+  }
+}
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
index 8898d4c..7077b53 100644
--- a/src/main/resources/Documentation/about.md
+++ b/src/main/resources/Documentation/about.md
@@ -7,8 +7,10 @@
 updates are not reviewable in the Gerrit UI, but they are
 downloadable as git refs. The @PLUGIN@ update service provides the
 tools to build these refs by merging changes to temporary "snapshot"
-refs, which can then be tested. The intent is to make the same exact
-(same git SHA1s) updates testable across potentially many machines.
+refs, which can then be tested and finally "submitted" as a "unit"
+if desired. The intent is to make the same exact (same git SHA1s)
+updates testable across potentially many machines, and to apply
+those exact SHA1s to the final destination refs on batch submittal.
 
 Creating Batches
 ----------------
@@ -83,6 +85,43 @@
 and should not be counted on to be stable, use the download_ref field to
 access the batch data instead of guessing at the format of this ref.
 
+Submitting Batches
+------------------
+As a final step, the CI system may, on success, submit the batch (using the
+batch id that it parsed from the json) like this:
+
+```
+$ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ submit --force \
+  0644a132-5b79-4c88-bf22-9364a1d02deb
+```
+
+This will then predictably apply the exact commits in the "sha1"
+entries to the respective destinations using a force push approach. This
+allows CI systems to test changes as if they were already merged on the
+destination branches instead of testing them "as is", which might
+otherwise mean testing changes which are outdated with respect to the
+destination branches.
+
+Because neither git nor Gerrit supports updating refs atomically across
+repositories, the forced push approach has been found to be the most
+reliable approach for CI systems to use to ensure that what they tested
+gets applied to their branches. Using a forced push strategy requires
+that the account submitting batches have FORCE PUSH permissons. To
+make this work reliably and to ensure that no history is ever lost, it
+is important that batches are only ever built from the current tips, and
+that those batches get submitted before any of the tips change. If other
+batches or changes are submitted to the batch's destination branches
+after the batch was created but before it is submitted using force push,
+then some history will likely be lost. The risk of this occurring is a
+generally seen as a worthwhile tradeoff to ensure that only what has been
+tested ever gets merged into a branch's history. It is advisable to only
+ever update branches that will have batches submitted to them by an actor
+that can create and submit batches "serially" and be the only actor
+updating these branches. This can be achieved by removing SUBMIT
+permissions from all accounts and giving a single account, used by a
+single process, FORCE PUSH permission to create and submit batches
+following the guidelines above.
+
 Batch Storage
 -------------
 In order to maintain state about which changes are in a batch, and where the
diff --git a/src/main/resources/Documentation/cmd-submit.md b/src/main/resources/Documentation/cmd-submit.md
new file mode 100644
index 0000000..d7cee08
--- /dev/null
+++ b/src/main/resources/Documentation/cmd-submit.md
@@ -0,0 +1,45 @@
+@PLUGIN@ submit
+===============
+
+NAME
+----
+@PLUGIN@ submit - Submit a batch to its destinations.
+
+SYNOPSIS
+--------
+```
+ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ submit <BATCH-ID>
+```
+
+DESCRIPTION
+-----------
+Submit the batch updates to their destinations, and force merge any
+Gerrit changes in the batch.  Once the batch is submitted, it will
+be automatically cleaned up.  This will effectively bypass any Gerrit
+submit rules for changes and will behave as if the batch SHA1s had
+been pushed directly to the destination branches.
+
+ACCESS
+------
+Caller must have permission to push updates to the destinations
+in the batch.
+
+SCRIPTING
+---------
+This command is intended to be used in scripts.
+
+OPTIONS
+-------
+
+--force
+
+	force push the batch updates
+
+EXAMPLES
+--------
+
+Submit a batch:
+
+```
+ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ submit 0644a132-5b79-4c88-bf22-9364a1d02deb
+```
diff --git a/test/test_batch_merge.sh b/test/test_batch_merge.sh
index 4f10234..cb7bc67 100755
--- a/test/test_batch_merge.sh
+++ b/test/test_batch_merge.sh
@@ -258,4 +258,15 @@
 ! bjson=$(batchssh merge-change --close "$ch2",1)
 result "$GROUP missing" "$bjson"
 
+
+setupGroup "submit" "Batch Submit" # -------------
+
+bjson=$(batchssh submit --force "$id")
+result "$GROUP submit" "$bjson"
+
+result_out "$GROUP submit dest_commit" "$sha1" "$(remote_show "$DEST_REF")"
+result_out "$GROUP submit state" "DELETED" "$(b_state)"
+result_out "$GROUP submit change_state" "MERGED" \
+    "$(query_by "$(query "$ch1")" "status")"
+
 exit $RESULT