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