Record submit records in notedb at submit time

Record a new footer, Submitted-with, to indicate the results of the
submit rule evaluator. Record additional normalized labels during
Submit by recording multiple commits in a single batch update, prior
to the update that actually submits the change.

Change-Id: Ia87913ad9d710fe0dea59ea56537e4ec1dd9f648
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
index 8adba94..86f77db 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
@@ -400,6 +400,23 @@
     setLastSha1MergeTested(null);
   }
 
+  public Change(Change other) {
+    changeId = other.changeId;
+    changeKey = other.changeKey;
+    rowVersion = other.rowVersion;
+    createdOn = other.createdOn;
+    lastUpdatedOn = other.lastUpdatedOn;
+    sortKey = other.sortKey;
+    owner = other.owner;
+    dest = other.dest;
+    open = other.open;
+    status = other.status;
+    currentPatchSetId = other.currentPatchSetId;
+    subject = other.subject;
+    topic = other.topic;
+    mergeable = other.mergeable;
+  }
+
   /** Legacy 32 bit integer identity for a change. */
   public Change.Id getId() {
     return changeId;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
index b2265a7..e09dfc0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
@@ -18,15 +18,19 @@
 
 import com.google.common.base.Optional;
 import com.google.common.base.Predicate;
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
+import com.google.common.collect.Table;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -35,12 +39,14 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.change.ChangeJson.ChangeInfo;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LabelNormalizer;
 import com.google.gerrit.server.git.MergeQueue;
+import com.google.gerrit.server.git.VersionedMetaData.BatchMetaDataUpdate;
 import com.google.gerrit.server.index.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ChangeControl;
@@ -51,6 +57,8 @@
 import com.google.inject.Provider;
 
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.PersonIdent;
 
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -78,8 +86,10 @@
     }
   }
 
+  private final PersonIdent serverIdent;
   private final Provider<ReviewDb> dbProvider;
   private final GitRepositoryManager repoManager;
+  private final IdentifiedUser.GenericFactory userFactory;
   private final ChangeUpdate.Factory updateFactory;
   private final ApprovalsUtil approvalsUtil;
   private final MergeQueue mergeQueue;
@@ -87,15 +97,19 @@
   private final LabelNormalizer labelNormalizer;
 
   @Inject
-  Submit(Provider<ReviewDb> dbProvider,
+  Submit(@GerritPersonIdent PersonIdent serverIdent,
+      Provider<ReviewDb> dbProvider,
       GitRepositoryManager repoManager,
+      IdentifiedUser.GenericFactory userFactory,
       ChangeUpdate.Factory updateFactory,
       ApprovalsUtil approvalsUtil,
       MergeQueue mergeQueue,
       ChangeIndexer indexer,
       LabelNormalizer labelNormalizer) {
+    this.serverIdent = serverIdent;
     this.dbProvider = dbProvider;
     this.repoManager = repoManager;
+    this.userFactory = userFactory;
     this.updateFactory = updateFactory;
     this.approvalsUtil = approvalsUtil;
     this.mergeQueue = mergeQueue;
@@ -125,8 +139,7 @@
           rsrc.getPatchSet().getRevision().get()));
     }
 
-    checkSubmitRule(rsrc);
-    change = submit(rsrc, caller);
+    change = submit(rsrc, caller, false);
     if (change == null) {
       throw new ResourceConflictException("change is "
           + status(dbProvider.get().changes().get(rsrc.getChange().getId())));
@@ -189,15 +202,22 @@
       }), null);
   }
 
-  public Change submit(RevisionResource rsrc, IdentifiedUser caller)
-      throws OrmException, IOException {
+  public Change submit(RevisionResource rsrc, IdentifiedUser caller,
+      boolean force) throws ResourceConflictException, OrmException,
+      IOException {
+    List<SubmitRecord> submitRecords = checkSubmitRule(rsrc, force);
     final Timestamp timestamp = TimeUtil.nowTs();
     Change change = rsrc.getChange();
     ChangeUpdate update = updateFactory.create(rsrc.getControl(), timestamp);
+    update.submit(submitRecords);
+
     ReviewDb db = dbProvider.get();
     db.changes().beginTransaction(change.getId());
     try {
-      approve(rsrc, update, caller, timestamp);
+      BatchMetaDataUpdate batch = approve(rsrc, update, caller, timestamp);
+      // Write update commit after all normalized label commits.
+      batch.write(update, new CommitBuilder());
+
       change = db.changes().atomicUpdate(
         change.getId(),
         new AtomicUpdate<Change>() {
@@ -223,8 +243,9 @@
     return change;
   }
 
-  private void approve(RevisionResource rsrc, ChangeUpdate update,
-      IdentifiedUser caller, Timestamp timestamp) throws OrmException {
+  private BatchMetaDataUpdate approve(RevisionResource rsrc,
+      ChangeUpdate update, IdentifiedUser caller, Timestamp timestamp)
+      throws OrmException {
     PatchSet.Id psId = rsrc.getPatchSet().getId();
     List<PatchSetApproval> approvals =
         approvalsUtil.byPatchSet(dbProvider.get(), rsrc.getNotes(), psId);
@@ -261,19 +282,71 @@
     // TODO(dborowitz): Don't use a label in notedb; just check when status
     // change happened.
     update.putApproval(submit.getLabel(), submit.getValue());
+
     dbProvider.get().patchSetApprovals().upsert(normalized.getNormalized());
     dbProvider.get().patchSetApprovals().delete(normalized.getDeleted());
+
+    try {
+      return saveToBatch(rsrc, update, normalized, timestamp);
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
   }
 
-  private void checkSubmitRule(RevisionResource rsrc)
-      throws ResourceConflictException {
-  List<SubmitRecord> results = rsrc.getControl().canSubmit(
+  private BatchMetaDataUpdate saveToBatch(RevisionResource rsrc,
+      ChangeUpdate callerUpdate, LabelNormalizer.Result normalized,
+      Timestamp timestamp) throws IOException {
+    Table<Account.Id, String, Optional<Short>> byUser = HashBasedTable.create();
+    for (PatchSetApproval psa : normalized.getUpdated()) {
+      byUser.put(psa.getAccountId(), psa.getLabel(),
+          Optional.of(psa.getValue()));
+    }
+    for (PatchSetApproval psa : normalized.getDeleted()) {
+      byUser.put(psa.getAccountId(), psa.getLabel(), Optional.<Short> absent());
+    }
+
+    ChangeControl ctl = rsrc.getControl();
+    BatchMetaDataUpdate batch = callerUpdate.openUpdate();
+    for (Account.Id accountId : byUser.rowKeySet()) {
+      if (!accountId.equals(callerUpdate.getUser().getAccountId())) {
+        ChangeUpdate update = updateFactory.create(
+            ctl.forUser(userFactory.create(dbProvider, accountId)), timestamp);
+        update.setSubject("Finalize approvals at submit");
+        putApprovals(update, byUser.row(accountId));
+
+        CommitBuilder commit = new CommitBuilder();
+        commit.setCommitter(new PersonIdent(serverIdent, timestamp));
+        batch.write(update, commit);
+      }
+    }
+
+    putApprovals(callerUpdate,
+        byUser.row(callerUpdate.getUser().getAccountId()));
+    return batch;
+  }
+
+  private static void putApprovals(ChangeUpdate update,
+      Map<String, Optional<Short>> approvals) {
+    for (Map.Entry<String, Optional<Short>> e : approvals.entrySet()) {
+      if (e.getValue().isPresent()) {
+        update.putApproval(e.getKey(), e.getValue().get());
+      } else {
+        update.removeApproval(e.getKey());
+      }
+    }
+  }
+
+  private List<SubmitRecord> checkSubmitRule(RevisionResource rsrc,
+      boolean force) throws ResourceConflictException {
+    List<SubmitRecord> results = rsrc.getControl().canSubmit(
         dbProvider.get(),
         rsrc.getPatchSet());
     Optional<SubmitRecord> ok = findOkRecord(results);
     if (ok.isPresent()) {
       // Rules supplied a valid solution.
-      return;
+      return ImmutableList.of(ok.get());
+    } else if (force) {
+      return results;
     } else if (results.isEmpty()) {
       throw new IllegalStateException(String.format(
           "ChangeControl.canSubmit returned empty list for %s in %s",
@@ -333,6 +406,7 @@
               rsrc.getChange().getProject().get()));
       }
     }
+    throw new IllegalStateException();
   }
 
   private static Optional<SubmitRecord> findOkRecord(Collection<SubmitRecord> in) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
index e602ccb..ed4f9ab 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
@@ -54,6 +54,7 @@
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicMap.Entry;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -1618,7 +1619,13 @@
       throws OrmException, IOException {
     Submit submit = submitProvider.get();
     RevisionResource rsrc = new RevisionResource(changes.parse(changeCtl), ps);
-    Change c = submit.submit(rsrc, currentUser);
+    Change c;
+    try {
+      // Force submit even if submit rule evaluation fails.
+      c = submit.submit(rsrc, currentUser, true);
+    } catch (ResourceConflictException e) {
+      throw new IOException(e);
+    }
     if (c == null) {
       addError("Submitting change " + changeCtl.getChange().getChangeId()
           + " failed.");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
index 83119de..e8012f2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
@@ -183,9 +183,6 @@
           : args.approvalsUtil.byPatchSet(args.db, n.notes(), n.getPatchsetId())) {
         approvals.add(new PatchSetApproval(ps.getId(), a));
       }
-      // TODO(dborowitz): This doesn't copy labels in the notedb. We should
-      // stamp those in atomically with the same commit that describes the
-      // change being submitted.
       args.db.patchSetApprovals().insert(approvals);
 
       ru = args.repo.updateRef(ps.getRefName());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java
index c31c92e1..7a6ce7a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java
@@ -94,9 +94,6 @@
                     newMergeTip, args.mergeUtil, committerIdent,
                     false, false, ValidatePolicy.NONE);
 
-            // TODO(dborowitz): This doesn't copy labels in the notedb. We
-            // should stamp those in atomically with the same commit that
-            // describes the change being submitted.
             List<PatchSetApproval> approvals = Lists.newArrayList();
             for (PatchSetApproval a : args.approvalsUtil.byPatchSet(
                 args.db, n.notes(), n.getPatchsetId())) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 7051b76..7204735 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -24,6 +24,9 @@
 
   static final FooterKey FOOTER_LABEL = new FooterKey("Label");
   static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
+  static final FooterKey FOOTER_STATUS = new FooterKey("Status");
+  static final FooterKey FOOTER_SUBMITTED_WITH =
+      new FooterKey("Submitted-with");
 
   public static String changeRefName(Change.Id id) {
     StringBuilder r = new StringBuilder();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 90cbea6..286bf4ad 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -16,21 +16,27 @@
 
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.GERRIT_PLACEHOLDER_HOST;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Enums;
 import com.google.common.base.Function;
 import com.google.common.base.Optional;
 import com.google.common.base.Supplier;
 import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.Table;
 import com.google.common.collect.Tables;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -48,6 +54,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.RawParseUtils;
@@ -93,6 +100,8 @@
     private final Map<PatchSet.Id,
         Table<Account.Id, String, Optional<PatchSetApproval>>> approvals;
     private final Map<Account.Id, ReviewerState> reviewers;
+    private final List<SubmitRecord> submitRecords;
+    private Change.Status status;
 
     private Parser(Change.Id changeId, ObjectId tip, RevWalk walk) {
       this.changeId = changeId;
@@ -100,6 +109,7 @@
       this.walk = walk;
       approvals = Maps.newHashMap();
       reviewers = Maps.newLinkedHashMap();
+      submitRecords = Lists.newArrayListWithExpectedSize(1);
     }
 
     private void parseAll() throws ConfigInvalidException, IOException {
@@ -127,12 +137,22 @@
     }
 
     private void parse(RevCommit commit) throws ConfigInvalidException {
+      if (status == null) {
+        status = parseStatus(commit);
+      }
       PatchSet.Id psId = parsePatchSetId(commit);
       Account.Id accountId = parseIdent(commit);
 
+      if (submitRecords.isEmpty()) {
+        // Only parse the most recent set of submit records; any older ones are
+        // still there, but not currently used.
+        parseSubmitRecords(commit.getFooterLines(FOOTER_SUBMITTED_WITH));
+      }
+
       for (String line : commit.getFooterLines(FOOTER_LABEL)) {
         parseApproval(psId, accountId, commit, line);
       }
+
       for (ReviewerState state : ReviewerState.values()) {
         for (String line : commit.getFooterLines(state.getFooterKey())) {
           parseReviewer(state, line);
@@ -140,17 +160,31 @@
       }
     }
 
+    private Change.Status parseStatus(RevCommit commit)
+        throws ConfigInvalidException {
+      List<String> statusLines = commit.getFooterLines(FOOTER_STATUS);
+      if (statusLines.isEmpty()) {
+        return null;
+      } else if (statusLines.size() > 1) {
+        throw expectedOneFooter(FOOTER_STATUS, statusLines);
+      }
+      Optional<Change.Status> status = Enums.getIfPresent(
+          Change.Status.class, statusLines.get(0).toUpperCase());
+      if (!status.isPresent()) {
+        throw invalidFooter(FOOTER_STATUS, statusLines.get(0));
+      }
+      return status.get();
+    }
+
     private PatchSet.Id parsePatchSetId(RevCommit commit)
         throws ConfigInvalidException {
       List<String> psIdLines = commit.getFooterLines(FOOTER_PATCH_SET);
       if (psIdLines.size() != 1) {
-        throw parseException("missing or multiple %s: %s",
-            FOOTER_PATCH_SET, psIdLines);
+        throw expectedOneFooter(FOOTER_PATCH_SET, psIdLines);
       }
       Integer psId = Ints.tryParse(psIdLines.get(0));
       if (psId == null) {
-        throw parseException("invalid %s: %s",
-            FOOTER_PATCH_SET, psIdLines.get(0));
+        throw invalidFooter(FOOTER_PATCH_SET, psIdLines.get(0));
       }
       return new PatchSet.Id(changeId, psId);
     }
@@ -199,6 +233,50 @@
       }
     }
 
+    private void parseSubmitRecords(List<String> lines)
+        throws ConfigInvalidException {
+      SubmitRecord rec = null;
+
+      for (String line : lines) {
+        int c = line.indexOf(": ");
+        if (c < 0) {
+          rec = new SubmitRecord();
+          submitRecords.add(rec);
+          int s = line.indexOf(' ');
+          String statusStr = s >= 0 ? line.substring(0, s) : line;
+          Optional<SubmitRecord.Status> status =
+              Enums.getIfPresent(SubmitRecord.Status.class, statusStr);
+          checkFooter(status.isPresent(), FOOTER_SUBMITTED_WITH, line);
+          rec.status = status.get();
+          if (s >= 0) {
+            rec.errorMessage = line.substring(s);
+          }
+        } else {
+          checkFooter(rec != null, FOOTER_SUBMITTED_WITH, line);
+          SubmitRecord.Label label = new SubmitRecord.Label();
+          if (rec.labels == null) {
+            rec.labels = Lists.newArrayList();
+          }
+          rec.labels.add(label);
+
+          Optional<SubmitRecord.Label.Status> status = Enums.getIfPresent(
+              SubmitRecord.Label.Status.class, line.substring(0, c));
+          checkFooter(status.isPresent(), FOOTER_SUBMITTED_WITH, line);
+          label.status = status.get();
+          int c2 = line.indexOf(": ", c + 2);
+          if (c2 >= 0) {
+            label.label = line.substring(c + 2, c2);
+            PersonIdent ident =
+                RawParseUtils.parsePersonIdent(line.substring(c2 + 2));
+            checkFooter(ident != null, FOOTER_SUBMITTED_WITH, line);
+            label.appliedBy = parseIdent(ident);
+          } else {
+            label.label = line.substring(c + 2);
+          }
+        }
+      }
+    }
+
     private Account.Id parseIdent(RevCommit commit)
         throws ConfigInvalidException {
       return parseIdent(commit.getAuthorIdent());
@@ -223,8 +301,7 @@
         throws ConfigInvalidException {
       PersonIdent ident = RawParseUtils.parsePersonIdent(line);
       if (ident == null) {
-        throw parseException(
-            "invalid %s: %s", state.getFooterKey().getName(), line);
+        throw invalidFooter(state.getFooterKey(), line);
       }
       Account.Id accountId = parseIdent(ident);
       if (!reviewers.containsKey(accountId)) {
@@ -250,6 +327,24 @@
       return new ConfigInvalidException("Change " + changeId + ": "
           + String.format(fmt, args));
     }
+
+    private ConfigInvalidException expectedOneFooter(FooterKey footer,
+        List<String> actual) {
+      return parseException("missing or multiple %s: %s",
+          footer.getName(), actual);
+    }
+
+    private ConfigInvalidException invalidFooter(FooterKey footer,
+        String actual) {
+      return parseException("invalid %s: %s", footer.getName(), actual);
+    }
+
+    private void checkFooter(boolean expr, FooterKey footer, String actual)
+        throws ConfigInvalidException {
+      if (!expr) {
+        throw invalidFooter(footer, actual);
+      }
+    }
   }
 
   private final GitRepositoryManager repoManager;
@@ -257,11 +352,12 @@
   private boolean loaded;
   private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals;
   private ImmutableSetMultimap<ReviewerState, Account.Id> reviewers;
+  private ImmutableList<SubmitRecord> submitRecords;
 
   @VisibleForTesting
   ChangeNotes(GitRepositoryManager repoManager, Change change) {
     this.repoManager = repoManager;
-    this.change = change;
+    this.change = new Change(change);
   }
 
   // TODO(dborowitz): Wrap fewer exceptions if/when we kill gwtorm.
@@ -301,6 +397,14 @@
     return reviewers;
   }
 
+  /**
+   * @return submit records stored during the most recent submit; only for
+   *     changes that were actually submitted.
+   */
+  public ImmutableList<SubmitRecord> getSubmitRecords() {
+    return submitRecords;
+  }
+
   @Override
   protected String getRefName() {
     return ChangeNoteUtil.changeRefName(change.getId());
@@ -316,7 +420,12 @@
     try {
       Parser parser = new Parser(change.getId(), rev, walk);
       parser.parseAll();
+
+      if (parser.status != null) {
+        change.setStatus(parser.status);
+      }
       approvals = parser.buildApprovals();
+
       ImmutableSetMultimap.Builder<ReviewerState, Account.Id> reviewers =
           ImmutableSetMultimap.builder();
       for (Map.Entry<Account.Id, ReviewerState> e
@@ -324,6 +433,7 @@
         reviewers.put(e.getValue(), e.getKey());
       }
       this.reviewers = reviewers.build();
+      submitRecords = ImmutableList.copyOf(parser.submitRecords);
     } finally {
       walk.release();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 8a6f128..74ca198 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -17,11 +17,15 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.GERRIT_PLACEHOLDER_HOST;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Maps;
+import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -49,6 +53,7 @@
 import java.io.IOException;
 import java.util.Comparator;
 import java.util.Date;
+import java.util.List;
 import java.util.Map;
 
 /**
@@ -79,8 +84,10 @@
   private final Date when;
   private final Map<String, Optional<Short>> approvals;
   private final Map<Account.Id, ReviewerState> reviewers;
+  private Change.Status status;
   private String subject;
   private PatchSet.Id psId;
+  private List<SubmitRecord> submitRecords;
 
   @AssistedInject
   private ChangeUpdate(
@@ -90,6 +97,7 @@
       AccountCache accountCache,
       MetaDataUpdate.User updateFactory,
       ProjectCache projectCache,
+      IdentifiedUser user,
       @Assisted ChangeControl ctl) {
     this(serverIdent, repoManager, migration, accountCache, updateFactory,
         projectCache, ctl, serverIdent.getWhen());
@@ -147,6 +155,12 @@
     return when;
   }
 
+  public void setStatus(Change.Status status) {
+    checkArgument(status != Change.Status.SUBMITTED,
+        "use submit(Iterable<PatchSetApproval>)");
+    this.status = status;
+  }
+
   public void putApproval(String label, short value) {
     approvals.put(label, Optional.of(value));
   }
@@ -155,6 +169,13 @@
     approvals.put(label, Optional.<Short> absent());
   }
 
+  public void submit(Iterable<SubmitRecord> submitRecords) {
+    status = Change.Status.SUBMITTED;
+    this.submitRecords = ImmutableList.copyOf(submitRecords);
+    checkArgument(!this.submitRecords.isEmpty(),
+        "no submit records specified at submit time");
+  }
+
   public void setSubject(String subject) {
     this.subject = subject;
   }
@@ -202,7 +223,7 @@
     }
   }
 
-  public PersonIdent newIdent(Account author) {
+  private PersonIdent newIdent(Account author, Date when) {
     return new PersonIdent(
         author.getFullName(),
         author.getId().get() + "@" + GERRIT_PLACEHOLDER_HOST,
@@ -262,10 +283,10 @@
 
   @Override
   protected boolean onSave(CommitBuilder commit) {
-    if (approvals.isEmpty() && reviewers.isEmpty()) {
+    if (isEmpty()) {
       return false;
     }
-    commit.setAuthor(newIdent(getUser().getAccount()));
+    commit.setAuthor(newIdent(getUser().getAccount(), when));
     commit.setCommitter(new PersonIdent(serverIdent, when));
 
     int ps = psId != null ? psId.get() : getChange().currentPatchSetId().get();
@@ -277,9 +298,13 @@
     }
     msg.append("\n\n");
     addFooter(msg, FOOTER_PATCH_SET, ps);
+    if (status != null) {
+      addFooter(msg, FOOTER_STATUS, status.name().toLowerCase());
+    }
+
     for (Map.Entry<Account.Id, ReviewerState> e : reviewers.entrySet()) {
       Account account = accountCache.get(e.getKey()).getAccount();
-      PersonIdent ident = newIdent(account);
+      PersonIdent ident = newIdent(account, when);
       addFooter(msg, e.getValue().getFooterKey())
           .append(ident.getName())
           .append(" <").append(ident.getEmailAddress()).append(">\n");
@@ -293,10 +318,43 @@
             new LabelVote(e.getKey(), e.getValue().get()).formatWithEquals());
       }
     }
+
+    if (submitRecords != null) {
+      for (SubmitRecord rec : submitRecords) {
+        addFooter(msg, FOOTER_SUBMITTED_WITH)
+            .append(rec.status);
+        if (rec.errorMessage != null) {
+          msg.append(' ').append(sanitizeFooter(rec.errorMessage));
+        }
+        msg.append('\n');
+
+        if (rec.labels != null) {
+          for (SubmitRecord.Label label : rec.labels) {
+            addFooter(msg, FOOTER_SUBMITTED_WITH)
+                .append(label.status).append(": ").append(label.label);
+            if (label.appliedBy != null) {
+              PersonIdent ident =
+                  newIdent(accountCache.get(label.appliedBy).getAccount(), when);
+              msg.append(": ").append(ident.getName())
+                  .append(" <").append(ident.getEmailAddress()).append('>');
+            }
+            msg.append('\n');
+          }
+        }
+      }
+    }
+
     commit.setMessage(msg.toString());
     return true;
   }
 
+  private boolean isEmpty() {
+    return approvals.isEmpty()
+        && reviewers.isEmpty()
+        && status == null
+        && submitRecords == null;
+  }
+
   private static StringBuilder addFooter(StringBuilder sb, FooterKey footer) {
     return sb.append(footer.getName()).append(": ");
   }
@@ -310,6 +368,10 @@
     sb.append('\n');
   }
 
+  private static String sanitizeFooter(String value) {
+    return value.replace('\n', ' ').replace('\0', ' ');
+  }
+
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
     // Do nothing; just reads current revision.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java
index 52f528f..13bc766 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java
@@ -19,6 +19,7 @@
 import com.google.common.base.Objects;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
 
 /** A single vote on a label, consisting of a label name and a value. */
 public class LabelVote {
@@ -64,6 +65,10 @@
     this.value = value;
   }
 
+  public LabelVote(PatchSetApproval psa) {
+    this(psa.getLabel(), psa.getValue());
+  }
+
   public String getLabel() {
     return name;
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 8d268f9..f2ad057 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -23,10 +23,12 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertTrue;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Ordering;
+import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -170,7 +172,7 @@
   }
 
   @Test
-  public void approvalsCommitFormat() throws Exception {
+  public void approvalsCommitFormatSimple() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.putApproval("Verified", (short) 1);
@@ -232,6 +234,79 @@
   }
 
   @Test
+  public void submitCommitFormat() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setSubject("Submit patch set 1");
+
+    update.submit(ImmutableList.of(
+        submitRecord("NOT_READY", null,
+          submitLabel("Verified", "OK", changeOwner.getAccountId()),
+          submitLabel("Code-Review", "NEED", null)),
+        submitRecord("NOT_READY", null,
+          submitLabel("Verified", "OK", changeOwner.getAccountId()),
+          submitLabel("Alternative-Code-Review", "NEED", null))));
+    update.commit();
+
+    RevWalk walk = new RevWalk(repo);
+    try {
+      RevCommit commit = walk.parseCommit(update.getRevision());
+      walk.parseBody(commit);
+      assertEquals("Submit patch set 1\n"
+          + "\n"
+          + "Patch-set: 1\n"
+          + "Status: submitted\n"
+          + "Submitted-with: NOT_READY\n"
+          + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+          + "Submitted-with: NEED: Code-Review\n"
+          + "Submitted-with: NOT_READY\n"
+          + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+          + "Submitted-with: NEED: Alternative-Code-Review\n",
+          commit.getFullMessage());
+
+      PersonIdent author = commit.getAuthorIdent();
+      assertEquals("Change Owner", author.getName());
+      assertEquals("1@gerrit", author.getEmailAddress());
+      assertEquals(new Date(c.getCreatedOn().getTime() + 1000),
+          author.getWhen());
+      assertEquals(TimeZone.getTimeZone("GMT-7:00"), author.getTimeZone());
+
+      PersonIdent committer = commit.getCommitterIdent();
+      assertEquals("Gerrit Server", committer.getName());
+      assertEquals("noreply@gerrit.com", committer.getEmailAddress());
+      assertEquals(author.getWhen(), committer.getWhen());
+      assertEquals(author.getTimeZone(), committer.getTimeZone());
+    } finally {
+      walk.release();
+    }
+  }
+
+  @Test
+  public void submitWithErrorMessage() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setSubject("Submit patch set 1");
+
+    update.submit(ImmutableList.of(
+        submitRecord("RULE_ERROR", "Problem with patch set:\n1")));
+    update.commit();
+
+    RevWalk walk = new RevWalk(repo);
+    try {
+      RevCommit commit = walk.parseCommit(update.getRevision());
+      walk.parseBody(commit);
+      assertEquals("Submit patch set 1\n"
+          + "\n"
+          + "Patch-set: 1\n"
+          + "Status: submitted\n"
+          + "Submitted-with: RULE_ERROR Problem with patch set: 1\n",
+          commit.getFullMessage());
+    } finally {
+      walk.release();
+    }
+  }
+
+  @Test
   public void approvalsOnePatchSet() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -452,6 +527,56 @@
   }
 
   @Test
+  public void submitRecords() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setSubject("Submit patch set 1");
+
+    update.submit(ImmutableList.of(
+        submitRecord("NOT_READY", null,
+          submitLabel("Verified", "OK", changeOwner.getAccountId()),
+          submitLabel("Code-Review", "NEED", null)),
+        submitRecord("NOT_READY", null,
+          submitLabel("Verified", "OK", changeOwner.getAccountId()),
+          submitLabel("Alternative-Code-Review", "NEED", null))));
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    List<SubmitRecord> recs = notes.getSubmitRecords();
+    assertEquals(2, recs.size());
+    assertEquals(submitRecord("NOT_READY", null,
+        submitLabel("Verified", "OK", changeOwner.getAccountId()),
+        submitLabel("Code-Review", "NEED", null)), recs.get(0));
+    assertEquals(submitRecord("NOT_READY", null,
+        submitLabel("Verified", "OK", changeOwner.getAccountId()),
+        submitLabel("Alternative-Code-Review", "NEED", null)), recs.get(1));
+  }
+
+  @Test
+  public void latestSubmitRecordsOnly() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setSubject("Submit patch set 1");
+    update.submit(ImmutableList.of(
+        submitRecord("OK", null,
+          submitLabel("Code-Review", "OK", otherUser.getAccountId()))));
+    update.commit();
+
+    incrementPatchSet(c);
+    update = newUpdate(c, changeOwner);
+    update.setSubject("Submit patch set 2");
+    update.submit(ImmutableList.of(
+        submitRecord("OK", null,
+          submitLabel("Code-Review", "OK", changeOwner.getAccountId()))));
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertEquals(submitRecord("OK", null,
+          submitLabel("Code-Review", "OK", changeOwner.getAccountId())),
+        Iterables.getOnlyElement(notes.getSubmitRecords()));
+  }
+
+  @Test
   public void multipleUpdatesInBatch() throws Exception {
     Change c = newChange();
     ChangeUpdate update1 = newUpdate(c, changeOwner);
@@ -535,4 +660,24 @@
     EasyMock.replay(ctl);
     return ctl;
   }
+
+  private static SubmitRecord submitRecord(String status,
+      String errorMessage, SubmitRecord.Label... labels) {
+    SubmitRecord rec = new SubmitRecord();
+    rec.status = SubmitRecord.Status.valueOf(status);
+    rec.errorMessage = errorMessage;
+    if (labels.length > 0) {
+      rec.labels = ImmutableList.copyOf(labels);
+    }
+    return rec;
+  }
+
+  private static SubmitRecord.Label submitLabel(String name, String status,
+      Account.Id appliedBy) {
+    SubmitRecord.Label label = new SubmitRecord.Label();
+    label.label = name;
+    label.status = SubmitRecord.Label.Status.valueOf(status);
+    label.appliedBy = appliedBy;
+    return label;
+  }
 }