Merge "Reset font weight for label columns"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 4cece72..be50d3b 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1218,15 +1218,6 @@
 +
 Default is true.
 
-[[change.enableParallelFormatting]]change.enableParallelFormatting::
-+
-Whether or not changes can be formatted in parallel when requesting
-multiple changes at once. An example for this is Dashboards.
-+
-This setting is experimental.
-+
-Default is `false`.
-
 [[change.showAssigneeInChangesTable]]change.showAssigneeInChangesTable::
 +
 Show assignee field in changes table. If set to false, assignees will
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index b4a4a57..37d6d01 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -20,6 +20,15 @@
 * `action/retry_timeout_count`: Number of action executions of RetryHelper
 that ultimately timed out
 
+=== Pushes
+
+* `receivecommits/changes`: histogram of number of changes processed
+in a single upload, split up by update type (new change created,
+existing changed updated, change autoclosed).
+* `receivecommits/latency`: latency per change for processing a push,
+split up by update type (create+replace, and autoclose)
+* `receivecommits/timeout`: number of timeouts during push processing.
+
 === Process
 
 * `proc/birth_timestamp`: Time at which the Gerrit process started.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 13c10b2..a340163 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -5318,8 +5318,8 @@
   }
 ----
 
-As response a link:#change-info[ChangeInfo] entity is returned that
-describes the resulting cherry picked change.
+As response a link:#cherry-pick-change-info[CherryPickChangeInfo]
+entity is returned that describes the resulting cherry-pick change.
 
 .Response
 ----
@@ -5921,6 +5921,23 @@
 Which patchset (if any) generated this message.
 |==================================
 
+[[cherry-pick-change-info]]
+=== CherryPickChangeInfo
+The `CherryPickChangeInfo` entity contains information about a
+cherry-pick change.
+
+`CherryPickChangeInfo` has the same fields as link:#change-info[
+ChangeInfo]. In addition `CherryPickChangeInfo` has the following
+fields:
+
+[options="header",cols="1,^1,5"]
+|======================================
+|Field Name               ||Description
+|`contains_git_conflicts` |optional, not set if `false`|
+Whether any file in the change contains Git conflict markers.
+|======================================
+
+
 [[cherrypick-input]]
 === CherryPickInput
 The `CherryPickInput` entity contains information for cherry-picking a change to a new branch.
@@ -5928,7 +5945,7 @@
 [options="header",cols="1,^1,5"]
 |===========================
 |Field Name         ||Description
-|`message`          ||Commit message for the cherry-picked change
+|`message`          ||Commit message for the cherry-pick change
 |`destination`      ||Destination branch
 |`base`             |optional|
 40-hex digit SHA-1 of the commit which will be the parent commit of the newly created change.
@@ -5944,7 +5961,15 @@
 Additional information about whom to notify about the update as a map
 of recipient type to link:#notify-info[NotifyInfo] entity.
 |`keep_reviewers`   |optional, defaults to false|
-If true, carries reviewers and ccs over from original change to newly created one.
+If `true`, carries reviewers and ccs over from original change to newly created one.
+|`allow_conflicts`  |optional, defaults to false|
+If `true`, the cherry-pick uses content merge and succeeds also if
+there are conflicts. If there are conflicts the file contents of the
+created change contain git conflict markers to indicate the conflicts.
+Callers can find out if there were conflicts by checking the
+`contains_git_conflicts` field in the link:#cherry-pick-change-info[
+CherryPickChangeInfo] that is returned by the cherry-pick REST
+endpoints.
 |===========================
 
 [[comment-info]]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 944e7b0..58e67e8 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -2596,8 +2596,9 @@
   }
 ----
 
-As response a link:rest-api-changes.html#change-info[ChangeInfo] entity is returned that
-describes the resulting cherry-picked change.
+As response a link:rest-api-changes.html#cherry-pick-change-info[
+CherryPickChangeInfo] entity is returned that describes the resulting
+cherry-picked change.
 
 .Response
 ----
diff --git a/Documentation/user-inline-edit.txt b/Documentation/user-inline-edit.txt
index ada2560..f64c449 100644
--- a/Documentation/user-inline-edit.txt
+++ b/Documentation/user-inline-edit.txt
@@ -183,7 +183,7 @@
 those changes that contain edits, see link:user-search.html#has[has:edit].
 
 
-[change-edit-actions]
+[[change-edit-actions]]
 == Modifying Changes
 
 
@@ -233,4 +233,4 @@
 
 Part of link:index.html[Gerrit Code Review]
 
-SEARCHBOX
\ No newline at end of file
+SEARCHBOX
diff --git a/WORKSPACE b/WORKSPACE
index d8fd339..62331cd 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1085,12 +1085,12 @@
     sha1 = "a2baf2d4fdf03f31fbd39351a32bee25fcdfa1cf",
 )
 
-JACKSON_VERSION = "2.8.9"
+JACKSON_VERSION = "2.9.7"
 
 maven_jar(
     name = "jackson-core",
     artifact = "com.fasterxml.jackson.core:jackson-core:" + JACKSON_VERSION,
-    sha1 = "569b1752705da98f49aabe2911cc956ff7d8ed9d",
+    sha1 = "4b7f0e0dc527fab032e9800ed231080fdc3ac015",
 )
 
 maven_jar(
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index df57c27..a4945c7 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -195,8 +195,8 @@
       if (recipients.get(type).contains(email) != expected) {
         failWithoutActual(
             fact(
-                expected ? "notifies" : "doesn't notify",
-                "[\n" + type + ": " + users.emailToName(email) + "\n]"));
+                expected ? "should notify" : "shouldn't notify",
+                type + ": " + users.emailToName(email)));
       }
       if (expected) {
         accountedFor.add(email);
diff --git a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
index 694e06b..5ac67e7 100644
--- a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
@@ -28,4 +28,5 @@
   public Map<RecipientType, NotifyInfo> notifyDetails;
 
   public boolean keepReviewers;
+  public boolean allowConflicts;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java b/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
index 3a33de9..3c32d29 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
@@ -38,7 +38,7 @@
 
   @Override
   public String toString() {
-    return username;
+    return username != null ? username : email;
   }
 
   private ReviewerInfo() {}
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 72e762c..3665706 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.CherryPickChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.EditInfo;
@@ -53,6 +54,8 @@
 
   ChangeApi cherryPick(CherryPickInput in) throws RestApiException;
 
+  CherryPickChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException;
+
   ChangeApi rebase() throws RestApiException;
 
   ChangeApi rebase(RebaseInput in) throws RestApiException;
@@ -190,6 +193,11 @@
     }
 
     @Override
+    public CherryPickChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ChangeApi rebase() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/common/CherryPickChangeInfo.java b/java/com/google/gerrit/extensions/common/CherryPickChangeInfo.java
new file mode 100644
index 0000000..5e2b902
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/CherryPickChangeInfo.java
@@ -0,0 +1,19 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+public class CherryPickChangeInfo extends ChangeInfo {
+  public Boolean containsGitConflicts;
+}
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index e835e2b..48bd1c1 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -19,7 +19,13 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
@@ -38,22 +44,31 @@
 
 @Singleton
 public class AccountResolver {
+  private final Provider<CurrentUser> self;
   private final Realm realm;
   private final Accounts accounts;
   private final AccountCache byId;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final AccountControl.Factory accountControlFactory;
   private final Provider<InternalAccountQuery> accountQueryProvider;
   private final Emails emails;
 
   @Inject
   AccountResolver(
+      Provider<CurrentUser> self,
       Realm realm,
       Accounts accounts,
       AccountCache byId,
+      IdentifiedUser.GenericFactory userFactory,
+      AccountControl.Factory accountControlFactory,
       Provider<InternalAccountQuery> accountQueryProvider,
       Emails emails) {
+    this.self = self;
     this.realm = realm;
     this.accounts = accounts;
     this.byId = byId;
+    this.userFactory = userFactory;
+    this.accountControlFactory = accountControlFactory;
     this.accountQueryProvider = accountQueryProvider;
     this.emails = emails;
   }
@@ -62,11 +77,11 @@
    * Locate exactly one account matching the input string.
    *
    * @param input a string of the format "Full Name &lt;email@example&gt;", just the email address
-   *     ("email@example"), a full name ("Full Name"), an account id ("18419") or an user name
+   *     ("email@example"), a full name ("Full Name"), an account ID ("18419") or a user name
    *     ("username").
    * @return the single account that matches; null if no account matches or there are multiple
    *     candidates. If {@code input} is a numeric string, returns an account if and only if that
-   *     number corresponds to an actual account.
+   *     number corresponds to an actual account ID.
    */
   public Account find(String input) throws OrmException, IOException, ConfigInvalidException {
     Set<Account.Id> r = findAll(input);
@@ -92,11 +107,11 @@
    * Find all accounts matching the input string.
    *
    * @param input a string of the format "Full Name &lt;email@example&gt;", just the email address
-   *     ("email@example"), a full name ("Full Name"), an account id ("18419") or an user name
+   *     ("email@example"), a full name ("Full Name"), an account ID ("18419") or a user name
    *     ("username").
    * @return the accounts that match, empty set if none. Never null. If {@code input} is a numeric
-   *     string, returns a singleton set if that number corresponds to a real account, and an empty
-   *     set otherwise if it does not.
+   *     string, returns a singleton set if that number corresponds to a real account ID, and an
+   *     empty set otherwise if it does not.
    */
   public Set<Account.Id> findAll(String input)
       throws OrmException, IOException, ConfigInvalidException {
@@ -197,4 +212,74 @@
         .map(a -> a.getAccount().getId())
         .collect(toSet());
   }
+
+  /**
+   * Parses a account ID from a request body and returns the user.
+   *
+   * @param id ID of the account, can be a string of the format "{@code Full Name
+   *     <email@example.com>}", just the email address, a full name if it is unique, an account ID,
+   *     a user name or "{@code self}" for the calling user
+   * @return the user, never null.
+   * @throws UnprocessableEntityException thrown if the account ID cannot be resolved or if the
+   *     account is not visible to the calling user
+   */
+  public IdentifiedUser parse(String id)
+      throws AuthException, UnprocessableEntityException, OrmException, IOException,
+          ConfigInvalidException {
+    return parseOnBehalfOf(null, id);
+  }
+
+  /**
+   * Parses an account ID and returns the user without making any permission check whether the
+   * current user can see the account.
+   *
+   * @param id ID of the account, can be a string of the format "{@code Full Name
+   *     <email@example.com>}", just the email address, a full name if it is unique, an account ID,
+   *     a user name or "{@code self}" for the calling user
+   * @return the user, null if no user is found for the given account ID
+   * @throws AuthException thrown if 'self' is used as account ID and the current user is not
+   *     authenticated
+   * @throws OrmException
+   * @throws ConfigInvalidException
+   * @throws IOException
+   */
+  public IdentifiedUser parseId(String id)
+      throws AuthException, OrmException, IOException, ConfigInvalidException {
+    return parseIdOnBehalfOf(null, id);
+  }
+
+  /**
+   * Like {@link #parse(String)}, but also sets the {@link CurrentUser#getRealUser()} on the result.
+   */
+  public IdentifiedUser parseOnBehalfOf(@Nullable CurrentUser caller, String id)
+      throws AuthException, UnprocessableEntityException, OrmException, IOException,
+          ConfigInvalidException {
+    IdentifiedUser user = parseIdOnBehalfOf(caller, id);
+    if (user == null || !accountControlFactory.get().canSee(user.getAccount())) {
+      throw new UnprocessableEntityException(
+          String.format("Account '%s' is not found or ambiguous", id));
+    }
+    return user;
+  }
+
+  private IdentifiedUser parseIdOnBehalfOf(@Nullable CurrentUser caller, String id)
+      throws AuthException, OrmException, IOException, ConfigInvalidException {
+    if (id.equals("self")) {
+      CurrentUser user = self.get();
+      if (user.isIdentifiedUser()) {
+        return user.asIdentifiedUser();
+      } else if (user instanceof AnonymousUser) {
+        throw new AuthException("Authentication required");
+      } else {
+        return null;
+      }
+    }
+
+    Account match = find(id);
+    if (match == null) {
+      return null;
+    }
+    CurrentUser realUser = caller != null ? caller.getRealUser() : null;
+    return userFactory.runAs(null, match.getId(), realUser);
+  }
 }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 8357568..bd602a8 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.CherryPickChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.DescriptionInput;
@@ -290,6 +291,15 @@
   }
 
   @Override
+  public CherryPickChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException {
+    try {
+      return cherryPick.apply(revision, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot cherry pick", e);
+    }
+  }
+
+  @Override
   public RevisionReviewerApi reviewer(String id) throws RestApiException {
     try {
       return revisionReviewerApi.create(
diff --git a/java/com/google/gerrit/server/api/groups/GroupsImpl.java b/java/com/google/gerrit/server/api/groups/GroupsImpl.java
index bac6649..1fdeac1 100644
--- a/java/com/google/gerrit/server/api/groups/GroupsImpl.java
+++ b/java/com/google/gerrit/server/api/groups/GroupsImpl.java
@@ -26,10 +26,11 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.restapi.account.AccountsCollection;
 import com.google.gerrit.server.restapi.group.CreateGroup;
 import com.google.gerrit.server.restapi.group.GroupsCollection;
 import com.google.gerrit.server.restapi.group.ListGroups;
@@ -43,8 +44,9 @@
 
 @Singleton
 class GroupsImpl implements Groups {
-  private final AccountsCollection accounts;
+  private final AccountResolver accountResolver;
   private final GroupsCollection groups;
+  private final GroupResolver groupResolver;
   private final ProjectsCollection projects;
   private final Provider<ListGroups> listGroups;
   private final Provider<QueryGroups> queryGroups;
@@ -54,16 +56,18 @@
 
   @Inject
   GroupsImpl(
-      AccountsCollection accounts,
+      AccountResolver accountResolver,
       GroupsCollection groups,
+      GroupResolver groupResolver,
       ProjectsCollection projects,
       Provider<ListGroups> listGroups,
       Provider<QueryGroups> queryGroups,
       PermissionBackend permissionBackend,
       CreateGroup createGroup,
       GroupApiImpl.Factory api) {
-    this.accounts = accounts;
+    this.accountResolver = accountResolver;
     this.groups = groups;
+    this.groupResolver = groupResolver;
     this.projects = projects;
     this.listGroups = listGroups;
     this.queryGroups = queryGroups;
@@ -126,7 +130,7 @@
     }
 
     for (String group : req.getGroups()) {
-      list.addGroup(groups.parse(group).getGroupUUID());
+      list.addGroup(groupResolver.parse(group).getGroupUUID());
     }
 
     list.setVisibleToAll(req.getVisibleToAll());
@@ -137,7 +141,7 @@
 
     if (req.getUser() != null) {
       try {
-        list.setUser(accounts.parse(req.getUser()).getAccountId());
+        list.setUser(accountResolver.parse(req.getUser()).getAccountId());
       } catch (Exception e) {
         throw asRestApiException("Error looking up user " + req.getUser(), e);
       }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewersEmail.java b/java/com/google/gerrit/server/change/AddReviewersEmail.java
similarity index 94%
rename from java/com/google/gerrit/server/restapi/change/PostReviewersEmail.java
rename to java/com/google/gerrit/server/change/AddReviewersEmail.java
index 7540222..4173950 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewersEmail.java
+++ b/java/com/google/gerrit/server/change/AddReviewersEmail.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.restapi.change;
+package com.google.gerrit.server.change;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
@@ -32,17 +32,17 @@
 import java.util.Collection;
 
 @Singleton
-class PostReviewersEmail {
+public class AddReviewersEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final AddReviewerSender.Factory addReviewerSenderFactory;
 
   @Inject
-  PostReviewersEmail(AddReviewerSender.Factory addReviewerSenderFactory) {
+  AddReviewersEmail(AddReviewerSender.Factory addReviewerSenderFactory) {
     this.addReviewerSenderFactory = addReviewerSenderFactory;
   }
 
-  void emailReviewers(
+  public void emailReviewers(
       IdentifiedUser user,
       Change change,
       Collection<Account.Id> added,
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
new file mode 100644
index 0000000..e558d00
--- /dev/null
+++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -0,0 +1,272 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.extensions.events.ReviewerAdded;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+
+public class AddReviewersOp implements BatchUpdateOp {
+  public interface Factory {
+
+    /**
+     * Create a new op.
+     *
+     * <p>Users may be added by account or by email addresses, as determined by {@code accountIds}
+     * and {@code addresses}. The reviewer state for both accounts and email addresses is determined
+     * by {@code state}.
+     *
+     * @param accountIds account IDs to add.
+     * @param addresses email addresses to add.
+     * @param state resulting reviewer state.
+     * @param notify notification handling.
+     * @param accountsToNotify additional accounts to notify.
+     * @return batch update operation.
+     */
+    AddReviewersOp create(
+        Set<Account.Id> accountIds,
+        Collection<Address> addresses,
+        ReviewerState state,
+        @Nullable NotifyHandling notify,
+        ListMultimap<RecipientType, Account.Id> accountsToNotify);
+  }
+
+  @AutoValue
+  public abstract static class Result {
+    public abstract ImmutableList<PatchSetApproval> addedReviewers();
+
+    public abstract ImmutableList<Address> addedReviewersByEmail();
+
+    public abstract ImmutableList<Account.Id> addedCCs();
+
+    public abstract ImmutableList<Address> addedCCsByEmail();
+
+    static Builder builder() {
+      return new AutoValue_AddReviewersOp_Result.Builder();
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder setAddedReviewers(Iterable<PatchSetApproval> addedReviewers);
+
+      abstract Builder setAddedReviewersByEmail(Iterable<Address> addedReviewersByEmail);
+
+      abstract Builder setAddedCCs(Iterable<Account.Id> addedCCs);
+
+      abstract Builder setAddedCCsByEmail(Iterable<Address> addedCCsByEmail);
+
+      abstract Result build();
+    }
+  }
+
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ReviewerAdded reviewerAdded;
+  private final AccountCache accountCache;
+  private final ProjectCache projectCache;
+  private final AddReviewersEmail addReviewersEmail;
+  private final NotesMigration migration;
+  private final Set<Account.Id> accountIds;
+  private final Collection<Address> addresses;
+  private final ReviewerState state;
+  private final NotifyHandling notify;
+  private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
+
+  // Unlike addedCCs, addedReviewers is a PatchSetApproval because the AddReviewerResult returned
+  // via the REST API is supposed to include vote information.
+  private List<PatchSetApproval> addedReviewers = ImmutableList.of();
+  private Collection<Address> addedReviewersByEmail = ImmutableList.of();
+  private Collection<Account.Id> addedCCs = ImmutableList.of();
+  private Collection<Address> addedCCsByEmail = ImmutableList.of();
+
+  private Change change;
+  private PatchSet patchSet;
+  private Result opResult;
+
+  @Inject
+  AddReviewersOp(
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ReviewerAdded reviewerAdded,
+      AccountCache accountCache,
+      ProjectCache projectCache,
+      AddReviewersEmail addReviewersEmail,
+      NotesMigration migration,
+      @Assisted Set<Account.Id> accountIds,
+      @Assisted Collection<Address> addresses,
+      @Assisted ReviewerState state,
+      @Assisted @Nullable NotifyHandling notify,
+      @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    checkArgument(state == REVIEWER || state == CC, "must be %s or %s: %s", REVIEWER, CC, state);
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.reviewerAdded = reviewerAdded;
+    this.accountCache = accountCache;
+    this.projectCache = projectCache;
+    this.addReviewersEmail = addReviewersEmail;
+    this.migration = migration;
+
+    this.accountIds = accountIds;
+    this.addresses = addresses;
+    this.state = state;
+    this.notify = notify;
+    this.accountsToNotify = accountsToNotify;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws RestApiException, OrmException, IOException {
+    change = ctx.getChange();
+    if (!accountIds.isEmpty()) {
+      if (migration.readChanges() && state == CC) {
+        addedCCs =
+            approvalsUtil.addCcs(
+                ctx.getNotes(), ctx.getUpdate(change.currentPatchSetId()), accountIds);
+      } else {
+        addedReviewers =
+            approvalsUtil.addReviewers(
+                ctx.getDb(),
+                ctx.getNotes(),
+                ctx.getUpdate(change.currentPatchSetId()),
+                projectCache.checkedGet(change.getProject()).getLabelTypes(change.getDest()),
+                change,
+                accountIds);
+      }
+    }
+
+    ImmutableList<Address> addressesToAdd = ImmutableList.of();
+    ReviewerStateInternal internalState = ReviewerStateInternal.fromReviewerState(state);
+    if (migration.readChanges()) {
+      // TODO(dborowitz): This behavior should live in ApprovalsUtil or something, like addCcs does.
+      ImmutableSet<Address> existing = ctx.getNotes().getReviewersByEmail().byState(internalState);
+      addressesToAdd =
+          addresses.stream().filter(a -> !existing.contains(a)).collect(toImmutableList());
+
+      if (state == CC) {
+        addedCCsByEmail = addressesToAdd;
+      } else {
+        addedReviewersByEmail = addressesToAdd;
+      }
+      for (Address a : addressesToAdd) {
+        ctx.getUpdate(change.currentPatchSetId()).putReviewerByEmail(a, internalState);
+      }
+    }
+    if (addedCCs.isEmpty() && addedReviewers.isEmpty() && addressesToAdd.isEmpty()) {
+      return false;
+    }
+
+    checkAdded();
+
+    patchSet = psUtil.current(ctx.getDb(), ctx.getNotes());
+    return true;
+  }
+
+  private void checkAdded() {
+    // Should only affect either reviewers or CCs, not both. But the logic in updateChange is
+    // complex, so programmer error is conceivable.
+    boolean addedAnyReviewers = !addedReviewers.isEmpty() || !addedReviewersByEmail.isEmpty();
+    boolean addedAnyCCs = !addedCCs.isEmpty() || !addedCCsByEmail.isEmpty();
+    checkState(
+        !(addedAnyReviewers && addedAnyCCs),
+        "should not have added both reviewers and CCs:\n"
+            + "Arguments:\n"
+            + "  accountIds=%s\n"
+            + "  addresses=%s\n"
+            + "Results:\n"
+            + "  addedReviewers=%s\n"
+            + "  addedReviewersByEmail=%s\n"
+            + "  addedCCs=%s\n"
+            + "  addedCCsByEmail=%s",
+        accountIds,
+        addresses,
+        addedReviewers,
+        addedReviewersByEmail,
+        addedCCs,
+        addedCCsByEmail);
+  }
+
+  @Override
+  public void postUpdate(Context ctx) throws Exception {
+    opResult =
+        Result.builder()
+            .setAddedReviewers(addedReviewers)
+            .setAddedReviewersByEmail(addedReviewersByEmail)
+            .setAddedCCs(addedCCs)
+            .setAddedCCsByEmail(addedCCsByEmail)
+            .build();
+    addReviewersEmail.emailReviewers(
+        ctx.getUser().asIdentifiedUser(),
+        change,
+        Lists.transform(addedReviewers, PatchSetApproval::getAccountId),
+        addedCCs,
+        addedReviewersByEmail,
+        addedCCsByEmail,
+        notify,
+        accountsToNotify,
+        !change.isWorkInProgress());
+    if (!addedReviewers.isEmpty()) {
+      List<AccountState> reviewers =
+          addedReviewers
+              .stream()
+              .map(r -> accountCache.get(r.getAccountId()))
+              .flatMap(Streams::stream)
+              .collect(toList());
+      reviewerAdded.fire(change, patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
+    }
+  }
+
+  public Result getResult() {
+    checkState(opResult != null, "Batch update wasn't executed yet");
+    return opResult;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 5de50f6..ad41920 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -14,55 +14,36 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_COMMITS;
-import static com.google.gerrit.extensions.client.ListChangesOption.ALL_FILES;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHECK;
 import static com.google.gerrit.extensions.client.ListChangesOption.COMMIT_FOOTERS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
-import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_FILES;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.extensions.client.ListChangesOption.DOWNLOAD_COMMANDS;
 import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
-import static com.google.gerrit.extensions.client.ListChangesOption.PUSH_CERTIFICATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWER_UPDATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.SKIP_MERGEABLE;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
 import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
-import static com.google.gerrit.extensions.client.ListChangesOption.WEB_LINKS;
 import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
-import static com.google.gerrit.server.CommonConverters.toGitPerson;
 import static java.util.stream.Collectors.toList;
 
-import com.google.auto.value.AutoValue;
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Throwables;
-import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
-import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitRecord.Status;
 import com.google.gerrit.common.data.SubmitRequirement;
@@ -74,22 +55,13 @@
 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.CommitInfo;
-import com.google.gerrit.extensions.common.FetchInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.ProblemInfo;
-import com.google.gerrit.extensions.common.PushCertificateInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.common.VotingRangeInfo;
-import com.google.gerrit.extensions.common.WebLinkInfo;
-import com.google.gerrit.extensions.config.DownloadCommand;
-import com.google.gerrit.extensions.config.DownloadScheme;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.Extension;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.mail.Address;
@@ -100,40 +72,27 @@
 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.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.account.AccountInfoComparator;
 import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.account.GpgApiAdapter;
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.TrackingFooters;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -150,23 +109,11 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-import java.util.TreeMap;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
+import java.util.function.Supplier;
 
 /**
  * Produces {@link ChangeInfo} (which is serialized to JSON afterwards) from {@link ChangeData}.
@@ -183,7 +130,7 @@
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
       ChangeField.SUBMIT_RULE_OPTIONS_STRICT.toBuilder().build();
 
-  public static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
+  static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
       ImmutableSet.of(
           ALL_COMMITS,
           ALL_REVISIONS,
@@ -251,34 +198,21 @@
 
   private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> userProvider;
-  private final AnonymousUser anonymous;
   private final PermissionBackend permissionBackend;
-  private final GitRepositoryManager repoManager;
-  private final ProjectCache projectCache;
-  private final MergeUtil.Factory mergeUtilFactory;
-  private final IdentifiedUser.GenericFactory userFactory;
   private final ChangeData.Factory changeDataFactory;
-  private final FileInfoJson fileInfoJson;
   private final AccountLoader.Factory accountLoaderFactory;
-  private final DynamicMap<DownloadScheme> downloadSchemes;
-  private final DynamicMap<DownloadCommand> downloadCommands;
-  private final WebLinks webLinks;
   private final ImmutableSet<ListChangesOption> options;
   private final ChangeMessagesUtil cmUtil;
   private final Provider<ConsistencyChecker> checkerProvider;
   private final ActionJson actionJson;
-  private final GpgApiAdapter gpgApi;
   private final ChangeNotes.Factory notesFactory;
-  private final ChangeResource.Factory changeResourceFactory;
-  private final ChangeKindCache changeKindCache;
-  private final ApprovalsUtil approvalsUtil;
+  private final LabelsJson labelsJson;
   private final RemoveReviewerControl removeReviewerControl;
   private final TrackingFooters trackingFooters;
   private final Metrics metrics;
-  private final boolean enableParallelFormatting;
-  private final ExecutorService fanOutExecutor;
+  private final RevisionJson revisionJson;
+  private final boolean lazyLoad;
 
-  private boolean lazyLoad = true;
   private AccountLoader accountLoader;
   private FixInput fix;
   private PluginDefinedAttributesFactory pluginDefinedAttributesFactory;
@@ -287,70 +221,50 @@
   ChangeJson(
       Provider<ReviewDb> db,
       Provider<CurrentUser> user,
-      AnonymousUser au,
       PermissionBackend permissionBackend,
-      GitRepositoryManager repoManager,
-      ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory,
-      IdentifiedUser.GenericFactory uf,
       ChangeData.Factory cdf,
-      FileInfoJson fileInfoJson,
       AccountLoader.Factory ailf,
-      DynamicMap<DownloadScheme> downloadSchemes,
-      DynamicMap<DownloadCommand> downloadCommands,
-      WebLinks webLinks,
       ChangeMessagesUtil cmUtil,
       Provider<ConsistencyChecker> checkerProvider,
       ActionJson actionJson,
-      GpgApiAdapter gpgApi,
       ChangeNotes.Factory notesFactory,
-      ChangeResource.Factory changeResourceFactory,
-      ChangeKindCache changeKindCache,
-      ApprovalsUtil approvalsUtil,
+      LabelsJson.Factory labelsJsonFactory,
       RemoveReviewerControl removeReviewerControl,
       TrackingFooters trackingFooters,
       Metrics metrics,
-      @GerritServerConfig Config config,
-      @FanOutExecutor ExecutorService fanOutExecutor,
+      RevisionJson.Factory revisionJsonFactory,
       @Assisted Iterable<ListChangesOption> options) {
     this.db = db;
     this.userProvider = user;
-    this.anonymous = au;
     this.changeDataFactory = cdf;
     this.permissionBackend = permissionBackend;
-    this.repoManager = repoManager;
-    this.userFactory = uf;
-    this.projectCache = projectCache;
-    this.mergeUtilFactory = mergeUtilFactory;
-    this.fileInfoJson = fileInfoJson;
     this.accountLoaderFactory = ailf;
-    this.downloadSchemes = downloadSchemes;
-    this.downloadCommands = downloadCommands;
-    this.webLinks = webLinks;
     this.cmUtil = cmUtil;
     this.checkerProvider = checkerProvider;
     this.actionJson = actionJson;
-    this.gpgApi = gpgApi;
     this.notesFactory = notesFactory;
-    this.changeResourceFactory = changeResourceFactory;
-    this.changeKindCache = changeKindCache;
-    this.approvalsUtil = approvalsUtil;
+    this.labelsJson = labelsJsonFactory.create(options);
     this.removeReviewerControl = removeReviewerControl;
     this.trackingFooters = trackingFooters;
     this.metrics = metrics;
-    this.enableParallelFormatting = config.getBoolean("change", "enableParallelFormatting", false);
-    this.fanOutExecutor = fanOutExecutor;
+    this.revisionJson = revisionJsonFactory.create(options);
     this.options = Sets.immutableEnumSet(options);
+    this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
     logger.atFine().log("options = %s", options);
   }
 
-  /**
-   * See {@link ChangeData#setLazyLoad(boolean)}. If lazyLoad is set, converting data from
-   * index-backed {@link ChangeData} will fail with an exception.
-   */
-  public ChangeJson lazyLoad(boolean load) {
-    lazyLoad = load;
-    return this;
+  public static ApprovalInfo getApprovalInfo(
+      Account.Id id,
+      Integer value,
+      VotingRangeInfo permittedVotingRange,
+      String tag,
+      Timestamp date) {
+    ApprovalInfo ai = new ApprovalInfo(id.get());
+    ai.value = value;
+    ai.permittedVotingRange = permittedVotingRange;
+    ai.date = date;
+    ai.tag = tag;
+    return ai;
   }
 
   public ChangeJson fix(FixInput fix) {
@@ -371,6 +285,11 @@
   }
 
   public ChangeInfo format(Project.NameKey project, Change.Id id) throws OrmException {
+    return format(project, id, ChangeInfo::new);
+  }
+
+  public <I extends ChangeInfo> I format(
+      Project.NameKey project, Change.Id id, Supplier<I> changeInfoSupplier) throws OrmException {
     ChangeNotes notes;
     try {
       notes = notesFactory.createChecked(db.get(), project, id);
@@ -378,43 +297,23 @@
       if (!has(CHECK)) {
         throw e;
       }
-      return checkOnly(changeDataFactory.create(db.get(), project, id));
+      return checkOnly(changeDataFactory.create(db.get(), project, id), changeInfoSupplier);
     }
-    return format(changeDataFactory.create(db.get(), notes));
+    return format(changeDataFactory.create(db.get(), notes), changeInfoSupplier);
   }
 
   public ChangeInfo format(ChangeData cd) throws OrmException {
-    return format(cd, Optional.empty(), true);
+    return format(cd, ChangeInfo::new);
   }
 
-  private ChangeInfo format(
-      ChangeData cd, Optional<PatchSet.Id> limitToPsId, boolean fillAccountLoader)
+  public <I extends ChangeInfo> I format(ChangeData cd, Supplier<I> changeInfoSupplier)
       throws OrmException {
-    try {
-      if (fillAccountLoader) {
-        accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-        ChangeInfo res = toChangeInfo(cd, limitToPsId);
-        accountLoader.fill();
-        return res;
-      }
-      return toChangeInfo(cd, limitToPsId);
-    } catch (PatchListNotAvailableException
-        | GpgException
-        | OrmException
-        | IOException
-        | PermissionBackendException
-        | RuntimeException e) {
-      if (!has(CHECK)) {
-        Throwables.throwIfInstanceOf(e, OrmException.class);
-        throw new OrmException(e);
-      }
-      return checkOnly(cd);
-    }
+    return format(cd, Optional.empty(), true, changeInfoSupplier);
   }
 
   public ChangeInfo format(RevisionResource rsrc) throws OrmException {
     ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
-    return format(cd, Optional.of(rsrc.getPatchSet().getId()), true);
+    return format(cd, Optional.of(rsrc.getPatchSet().getId()), true, ChangeInfo::new);
   }
 
   public List<List<ChangeInfo>> formatQueryResults(List<QueryResult<ChangeData>> in)
@@ -442,7 +341,7 @@
     ensureLoaded(in);
     List<ChangeInfo> out = new ArrayList<>(in.size());
     for (ChangeData cd : in) {
-      out.add(format(cd, Optional.empty(), false));
+      out.add(format(cd, Optional.empty(), false, ChangeInfo::new));
     }
     accountLoader.fill();
     return out;
@@ -465,6 +364,45 @@
     return new SubmitRequirementInfo(status.name(), req.fallbackText(), req.type(), req.data());
   }
 
+  private static void finish(ChangeInfo info) {
+    info.id =
+        Joiner.on('~')
+            .join(Url.encode(info.project), Url.encode(info.branch), Url.encode(info.changeId));
+  }
+
+  private static boolean containsAnyOf(
+      ImmutableSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
+    return !Sets.intersection(toFind, set).isEmpty();
+  }
+
+  private <I extends ChangeInfo> I format(
+      ChangeData cd,
+      Optional<PatchSet.Id> limitToPsId,
+      boolean fillAccountLoader,
+      Supplier<I> changeInfoSupplier)
+      throws OrmException {
+    try {
+      if (fillAccountLoader) {
+        accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
+        I res = toChangeInfo(cd, limitToPsId, changeInfoSupplier);
+        accountLoader.fill();
+        return res;
+      }
+      return toChangeInfo(cd, limitToPsId, changeInfoSupplier);
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | OrmException
+        | IOException
+        | PermissionBackendException
+        | RuntimeException e) {
+      if (!has(CHECK)) {
+        Throwables.throwIfInstanceOf(e, OrmException.class);
+        throw new OrmException(e);
+      }
+      return checkOnly(cd, changeInfoSupplier);
+    }
+  }
+
   private void ensureLoaded(Iterable<ChangeData> all) throws OrmException {
     if (lazyLoad) {
       ChangeData.ensureChangeLoaded(all);
@@ -491,62 +429,32 @@
   private List<ChangeInfo> toChangeInfos(
       List<ChangeData> changes, Map<Change.Id, ChangeInfo> cache) {
     try (Timer0.Context ignored = metrics.toChangeInfosLatency.start()) {
-      // Create a list of formatting calls that can be called sequentially or in parallel
-      List<Callable<Optional<ChangeInfo>>> formattingCalls = new ArrayList<>(changes.size());
+      List<ChangeInfo> changeInfos = new ArrayList<>(changes.size());
       for (ChangeData cd : changes) {
-        formattingCalls.add(
-            () -> {
-              ChangeInfo i = cache.get(cd.getId());
-              if (i != null) {
-                return Optional.of(i);
-              }
-              try {
-                ensureLoaded(Collections.singleton(cd));
-                return Optional.of(format(cd, Optional.empty(), false));
-              } catch (OrmException | RuntimeException e) {
-                logger.atWarning().withCause(e).log(
-                    "Omitting corrupt change %s from results", cd.getId());
-                return Optional.empty();
-              }
-            });
-      }
-
-      long numProjects = changes.stream().map(ChangeData::project).distinct().count();
-      if (!enableParallelFormatting || !lazyLoad || changes.size() < 3 || numProjects < 2) {
-        // Format these changes in the request thread as the multithreading overhead would be too
-        // high.
-        List<ChangeInfo> result = new ArrayList<>(changes.size());
-        for (Callable<Optional<ChangeInfo>> c : formattingCalls) {
-          try {
-            c.call().ifPresent(result::add);
-          } catch (Exception e) {
-            logger.atWarning().withCause(e).log("Omitting change due to exception");
-          }
+        ChangeInfo i = cache.get(cd.getId());
+        if (i != null) {
+          continue;
         }
-        return result;
-      }
-
-      // Format the changes in parallel on the executor
-      List<ChangeInfo> result = new ArrayList<>(changes.size());
-      try {
-        for (Future<Optional<ChangeInfo>> f : fanOutExecutor.invokeAll(formattingCalls)) {
-          f.get().ifPresent(result::add);
+        try {
+          ensureLoaded(Collections.singleton(cd));
+          changeInfos.add(format(cd, Optional.empty(), false, ChangeInfo::new));
+        } catch (OrmException | RuntimeException e) {
+          logger.atWarning().withCause(e).log(
+              "Omitting corrupt change %s from results", cd.getId());
         }
-      } catch (InterruptedException | ExecutionException e) {
-        throw new IllegalStateException(e);
       }
-      return result;
+      return changeInfos;
     }
   }
 
-  private ChangeInfo checkOnly(ChangeData cd) {
+  private <I extends ChangeInfo> I checkOnly(ChangeData cd, Supplier<I> changeInfoSupplier) {
     ChangeNotes notes;
     try {
       notes = cd.notes();
     } catch (OrmException e) {
       String msg = "Error loading change";
       logger.atWarning().withCause(e).log(msg + " %s", cd.getId());
-      ChangeInfo info = new ChangeInfo();
+      I info = changeInfoSupplier.get();
       info._number = cd.getId().get();
       ProblemInfo p = new ProblemInfo();
       p.message = msg;
@@ -555,10 +463,9 @@
     }
 
     ConsistencyChecker.Result result = checkerProvider.get().check(notes, fix);
-    ChangeInfo info;
+    I info = changeInfoSupplier.get();
     Change c = result.change();
     if (c != null) {
-      info = new ChangeInfo();
       info.project = c.getProject().get();
       info.branch = c.getDest().getShortName();
       info.topic = c.getTopic();
@@ -575,25 +482,26 @@
       info.hasReviewStarted = c.hasReviewStarted();
       finish(info);
     } else {
-      info = new ChangeInfo();
       info._number = result.id().get();
       info.problems = result.problems();
     }
     return info;
   }
 
-  private ChangeInfo toChangeInfo(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
+  private <I extends ChangeInfo> I toChangeInfo(
+      ChangeData cd, Optional<PatchSet.Id> limitToPsId, Supplier<I> changeInfoSupplier)
       throws PatchListNotAvailableException, GpgException, OrmException, PermissionBackendException,
           IOException {
     try (Timer0.Context ignored = metrics.toChangeInfoLatency.start()) {
-      return toChangeInfoImpl(cd, limitToPsId);
+      return toChangeInfoImpl(cd, limitToPsId, changeInfoSupplier);
     }
   }
 
-  private ChangeInfo toChangeInfoImpl(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
+  private <I extends ChangeInfo> I toChangeInfoImpl(
+      ChangeData cd, Optional<PatchSet.Id> limitToPsId, Supplier<I> changeInfoSupplier)
       throws PatchListNotAvailableException, GpgException, OrmException, PermissionBackendException,
           IOException {
-    ChangeInfo out = new ChangeInfo();
+    I out = changeInfoSupplier.get();
     CurrentUser user = userProvider.get();
 
     if (has(CHECK)) {
@@ -654,7 +562,7 @@
       out.reviewed = cd.isReviewedBy(user.getAccountId()) ? true : null;
     }
 
-    out.labels = labelsFor(cd, has(LABELS), has(DETAILED_LABELS));
+    out.labels = labelsJson.labelsFor(accountLoader, cd, has(LABELS), has(DETAILED_LABELS));
     out.requirements = requirementsFor(cd);
 
     if (out.labels != null && has(DETAILED_LABELS)) {
@@ -664,7 +572,7 @@
           && (!limitToPsId.isPresent() || limitToPsId.get().equals(in.currentPatchSetId()))) {
         out.permittedLabels =
             cd.change().getStatus() != Change.Status.ABANDONED
-                ? permittedLabels(user.getAccountId(), cd)
+                ? labelsJson.permittedLabels(user.getAccountId(), cd)
                 : ImmutableMap.of();
       }
 
@@ -699,7 +607,7 @@
     // This block must come after the ChangeInfo is mostly populated, since
     // it will be passed to ActionVisitors as-is.
     if (needRevisions) {
-      out.revisions = revisions(cd, src, limitToPsId, out);
+      out.revisions = revisionJson.getRevisions(accountLoader, cd, src, limitToPsId, out);
       if (out.revisions != null) {
         for (Map.Entry<String, RevisionInfo> entry : out.revisions.entrySet()) {
           if (entry.getValue().isCurrent) {
@@ -760,210 +668,6 @@
     return SubmitRecord.allRecordsOK(cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT));
   }
 
-  private List<SubmitRecord> submitRecords(ChangeData cd) {
-    return cd.submitRecords(SUBMIT_RULE_OPTIONS_LENIENT);
-  }
-
-  private Map<String, LabelInfo> labelsFor(ChangeData cd, boolean standard, boolean detailed)
-      throws OrmException, PermissionBackendException {
-    if (!standard && !detailed) {
-      return null;
-    }
-
-    LabelTypes labelTypes = cd.getLabelTypes();
-    Map<String, LabelWithStatus> withStatus =
-        cd.change().getStatus() == Change.Status.MERGED
-            ? labelsForSubmittedChange(cd, labelTypes, standard, detailed)
-            : labelsForUnsubmittedChange(cd, labelTypes, standard, detailed);
-    return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
-  }
-
-  private Map<String, LabelWithStatus> labelsForUnsubmittedChange(
-      ChangeData cd, LabelTypes labelTypes, boolean standard, boolean detailed)
-      throws OrmException, PermissionBackendException {
-    Map<String, LabelWithStatus> labels = initLabels(cd, labelTypes, standard);
-    if (detailed) {
-      setAllApprovals(cd, labels);
-    }
-    for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
-      LabelType type = labelTypes.byLabel(e.getKey());
-      if (type == null) {
-        continue;
-      }
-      if (standard) {
-        for (PatchSetApproval psa : cd.currentApprovals()) {
-          if (type.matches(psa)) {
-            short val = psa.getValue();
-            Account.Id accountId = psa.getAccountId();
-            setLabelScores(type, e.getValue(), val, accountId);
-          }
-        }
-      }
-      if (detailed) {
-        setLabelValues(type, e.getValue());
-      }
-    }
-    return labels;
-  }
-
-  private Map<String, LabelWithStatus> initLabels(
-      ChangeData cd, LabelTypes labelTypes, boolean standard) {
-    Map<String, LabelWithStatus> labels = new TreeMap<>(labelTypes.nameComparator());
-    for (SubmitRecord rec : submitRecords(cd)) {
-      if (rec.labels == null) {
-        continue;
-      }
-      for (SubmitRecord.Label r : rec.labels) {
-        LabelWithStatus p = labels.get(r.label);
-        if (p == null || p.status().compareTo(r.status) < 0) {
-          LabelInfo n = new LabelInfo();
-          if (standard) {
-            switch (r.status) {
-              case OK:
-                n.approved = accountLoader.get(r.appliedBy);
-                break;
-              case REJECT:
-                n.rejected = accountLoader.get(r.appliedBy);
-                n.blocking = true;
-                break;
-              case IMPOSSIBLE:
-              case MAY:
-              case NEED:
-              default:
-                break;
-            }
-          }
-
-          n.optional = r.status == SubmitRecord.Label.Status.MAY ? true : null;
-          labels.put(r.label, LabelWithStatus.create(n, r.status));
-        }
-      }
-    }
-    return labels;
-  }
-
-  private void setLabelScores(
-      LabelType type, LabelWithStatus l, short score, Account.Id accountId) {
-    if (l.label().approved != null || l.label().rejected != null) {
-      return;
-    }
-
-    if (type.getMin() == null || type.getMax() == null) {
-      // Can't set score for unknown or misconfigured type.
-      return;
-    }
-
-    if (score != 0) {
-      if (score == type.getMin().getValue()) {
-        l.label().rejected = accountLoader.get(accountId);
-      } else if (score == type.getMax().getValue()) {
-        l.label().approved = accountLoader.get(accountId);
-      } else if (score < 0) {
-        l.label().disliked = accountLoader.get(accountId);
-        l.label().value = score;
-      } else if (score > 0 && l.label().disliked == null) {
-        l.label().recommended = accountLoader.get(accountId);
-        l.label().value = score;
-      }
-    }
-  }
-
-  private void setAllApprovals(ChangeData cd, Map<String, LabelWithStatus> labels)
-      throws OrmException, PermissionBackendException {
-    Change.Status status = cd.change().getStatus();
-    checkState(
-        status != Change.Status.MERGED, "should not call setAllApprovals on %s change", status);
-
-    // Include a user in the output for this label if either:
-    //  - They are an explicit reviewer.
-    //  - They ever voted on this change.
-    Set<Account.Id> allUsers = new HashSet<>();
-    allUsers.addAll(cd.reviewers().byState(ReviewerStateInternal.REVIEWER));
-    for (PatchSetApproval psa : cd.approvals().values()) {
-      allUsers.add(psa.getAccountId());
-    }
-
-    Table<Account.Id, String, PatchSetApproval> current =
-        HashBasedTable.create(allUsers.size(), cd.getLabelTypes().getLabelTypes().size());
-    for (PatchSetApproval psa : cd.currentApprovals()) {
-      current.put(psa.getAccountId(), psa.getLabel(), psa);
-    }
-
-    LabelTypes labelTypes = cd.getLabelTypes();
-    for (Account.Id accountId : allUsers) {
-      PermissionBackend.ForChange perm = permissionBackendForChange(accountId, cd);
-      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
-      for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
-        LabelType lt = labelTypes.byLabel(e.getKey());
-        if (lt == null) {
-          // Ignore submit record for undefined label; likely the submit rule
-          // author didn't intend for the label to show up in the table.
-          continue;
-        }
-        Integer value;
-        VotingRangeInfo permittedVotingRange = pvr.getOrDefault(lt.getName(), null);
-        String tag = null;
-        Timestamp date = null;
-        PatchSetApproval psa = current.get(accountId, lt.getName());
-        if (psa != null) {
-          value = Integer.valueOf(psa.getValue());
-          if (value == 0) {
-            // This may be a dummy approval that was inserted when the reviewer
-            // was added. Explicitly check whether the user can vote on this
-            // label.
-            value = perm.test(new LabelPermission(lt)) ? 0 : null;
-          }
-          tag = psa.getTag();
-          date = psa.getGranted();
-          if (psa.isPostSubmit()) {
-            logger.atWarning().log("unexpected post-submit approval on open change: %s", psa);
-          }
-        } else {
-          // Either the user cannot vote on this label, or they were added as a
-          // reviewer but have not responded yet. Explicitly check whether the
-          // user can vote on this label.
-          value = perm.test(new LabelPermission(lt)) ? 0 : null;
-        }
-        addApproval(
-            e.getValue().label(), approvalInfo(accountId, value, permittedVotingRange, tag, date));
-      }
-    }
-  }
-
-  private Map<String, VotingRangeInfo> getPermittedVotingRanges(
-      Map<String, Collection<String>> permittedLabels) {
-    Map<String, VotingRangeInfo> permittedVotingRanges =
-        Maps.newHashMapWithExpectedSize(permittedLabels.size());
-    for (String label : permittedLabels.keySet()) {
-      List<Integer> permittedVotingRange =
-          permittedLabels
-              .get(label)
-              .stream()
-              .map(this::parseRangeValue)
-              .filter(java.util.Objects::nonNull)
-              .sorted()
-              .collect(toList());
-
-      if (permittedVotingRange.isEmpty()) {
-        permittedVotingRanges.put(label, null);
-      } else {
-        int minPermittedValue = permittedVotingRange.get(0);
-        int maxPermittedValue = Iterables.getLast(permittedVotingRange);
-        permittedVotingRanges.put(label, new VotingRangeInfo(minPermittedValue, maxPermittedValue));
-      }
-    }
-    return permittedVotingRanges;
-  }
-
-  private Integer parseRangeValue(String value) {
-    if (value.startsWith("+")) {
-      value = value.substring(1);
-    } else if (value.startsWith(" ")) {
-      value = value.trim();
-    }
-    return Ints.tryParse(value);
-  }
-
   private void setSubmitter(ChangeData cd, ChangeInfo out) throws OrmException {
     Optional<PatchSetApproval> s = cd.getSubmitApproval();
     if (!s.isPresent()) {
@@ -973,214 +677,6 @@
     out.submitter = accountLoader.get(s.get().getAccountId());
   }
 
-  private Map<String, LabelWithStatus> labelsForSubmittedChange(
-      ChangeData cd, LabelTypes labelTypes, boolean standard, boolean detailed)
-      throws OrmException, PermissionBackendException {
-    Set<Account.Id> allUsers = new HashSet<>();
-    if (detailed) {
-      // Users expect to see all reviewers on closed changes, even if they
-      // didn't vote on the latest patch set. If we don't need detailed labels,
-      // we aren't including 0 votes for all users below, so we can just look at
-      // the latest patch set (in the next loop).
-      for (PatchSetApproval psa : cd.approvals().values()) {
-        allUsers.add(psa.getAccountId());
-      }
-    }
-
-    Set<String> labelNames = new HashSet<>();
-    SetMultimap<Account.Id, PatchSetApproval> current =
-        MultimapBuilder.hashKeys().hashSetValues().build();
-    for (PatchSetApproval a : cd.currentApprovals()) {
-      allUsers.add(a.getAccountId());
-      LabelType type = labelTypes.byLabel(a.getLabelId());
-      if (type != null) {
-        labelNames.add(type.getName());
-        // Not worth the effort to distinguish between votable/non-votable for 0
-        // values on closed changes, since they can't vote anyway.
-        current.put(a.getAccountId(), a);
-      }
-    }
-
-    // Since voting on merged changes is allowed all labels which apply to
-    // the change must be returned. All applying labels can be retrieved from
-    // the submit records, which is what initLabels does.
-    // It's not possible to only compute the labels based on the approvals
-    // since merged changes may not have approvals for all labels (e.g. if not
-    // all labels are required for submit or if the change was auto-closed due
-    // to direct push or if new labels were defined after the change was
-    // merged).
-    Map<String, LabelWithStatus> labels;
-    labels = initLabels(cd, labelTypes, standard);
-
-    // Also include all labels for which approvals exists. E.g. there can be
-    // approvals for labels that are ignored by a Prolog submit rule and hence
-    // it wouldn't be included in the submit records.
-    for (String name : labelNames) {
-      if (!labels.containsKey(name)) {
-        labels.put(name, LabelWithStatus.create(new LabelInfo(), null));
-      }
-    }
-
-    if (detailed) {
-      labels
-          .entrySet()
-          .stream()
-          .filter(e -> labelTypes.byLabel(e.getKey()) != null)
-          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()), e.getValue()));
-    }
-
-    for (Account.Id accountId : allUsers) {
-      Map<String, ApprovalInfo> byLabel = Maps.newHashMapWithExpectedSize(labels.size());
-      Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
-      if (detailed) {
-        pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
-        for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
-          ApprovalInfo ai = approvalInfo(accountId, 0, null, null, null);
-          byLabel.put(entry.getKey(), ai);
-          addApproval(entry.getValue().label(), ai);
-        }
-      }
-      for (PatchSetApproval psa : current.get(accountId)) {
-        LabelType type = labelTypes.byLabel(psa.getLabelId());
-        if (type == null) {
-          continue;
-        }
-
-        short val = psa.getValue();
-        ApprovalInfo info = byLabel.get(type.getName());
-        if (info != null) {
-          info.value = Integer.valueOf(val);
-          info.permittedVotingRange = pvr.getOrDefault(type.getName(), null);
-          info.date = psa.getGranted();
-          info.tag = psa.getTag();
-          if (psa.isPostSubmit()) {
-            info.postSubmit = true;
-          }
-        }
-        if (!standard) {
-          continue;
-        }
-
-        setLabelScores(type, labels.get(type.getName()), val, accountId);
-      }
-    }
-    return labels;
-  }
-
-  private ApprovalInfo approvalInfo(
-      Account.Id id,
-      Integer value,
-      VotingRangeInfo permittedVotingRange,
-      String tag,
-      Timestamp date) {
-    ApprovalInfo ai = getApprovalInfo(id, value, permittedVotingRange, tag, date);
-    accountLoader.put(ai);
-    return ai;
-  }
-
-  public static ApprovalInfo getApprovalInfo(
-      Account.Id id,
-      Integer value,
-      VotingRangeInfo permittedVotingRange,
-      String tag,
-      Timestamp date) {
-    ApprovalInfo ai = new ApprovalInfo(id.get());
-    ai.value = value;
-    ai.permittedVotingRange = permittedVotingRange;
-    ai.date = date;
-    ai.tag = tag;
-    return ai;
-  }
-
-  private static boolean isOnlyZero(Collection<String> values) {
-    return values.isEmpty() || (values.size() == 1 && values.contains(" 0"));
-  }
-
-  private void setLabelValues(LabelType type, LabelWithStatus l) {
-    l.label().defaultValue = type.getDefaultValue();
-    l.label().values = new LinkedHashMap<>();
-    for (LabelValue v : type.getValues()) {
-      l.label().values.put(v.formatValue(), v.getText());
-    }
-    if (isOnlyZero(l.label().values.keySet())) {
-      l.label().values = null;
-    }
-  }
-
-  private Map<String, Collection<String>> permittedLabels(
-      Account.Id filterApprovalsBy, ChangeData cd) throws OrmException, PermissionBackendException {
-    boolean isMerged = cd.change().getStatus() == Change.Status.MERGED;
-    LabelTypes labelTypes = cd.getLabelTypes();
-    Map<String, LabelType> toCheck = new HashMap<>();
-    for (SubmitRecord rec : submitRecords(cd)) {
-      if (rec.labels != null) {
-        for (SubmitRecord.Label r : rec.labels) {
-          LabelType type = labelTypes.byLabel(r.label);
-          if (type != null && (!isMerged || type.allowPostSubmit())) {
-            toCheck.put(type.getName(), type);
-          }
-        }
-      }
-    }
-
-    Map<String, Short> labels = null;
-    Set<LabelPermission.WithValue> can =
-        permissionBackendForChange(filterApprovalsBy, cd).testLabels(toCheck.values());
-    SetMultimap<String, String> permitted = LinkedHashMultimap.create();
-    for (SubmitRecord rec : submitRecords(cd)) {
-      if (rec.labels == null) {
-        continue;
-      }
-      for (SubmitRecord.Label r : rec.labels) {
-        LabelType type = labelTypes.byLabel(r.label);
-        if (type == null || (isMerged && !type.allowPostSubmit())) {
-          continue;
-        }
-
-        for (LabelValue v : type.getValues()) {
-          boolean ok = can.contains(new LabelPermission.WithValue(type, v));
-          if (isMerged) {
-            if (labels == null) {
-              labels = currentLabels(filterApprovalsBy, cd);
-            }
-            short prev = labels.getOrDefault(type.getName(), (short) 0);
-            ok &= v.getValue() >= prev;
-          }
-          if (ok) {
-            permitted.put(r.label, v.formatValue());
-          }
-        }
-      }
-    }
-
-    List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
-    for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
-      if (isOnlyZero(e.getValue())) {
-        toClear.add(e.getKey());
-      }
-    }
-    for (String label : toClear) {
-      permitted.removeAll(label);
-    }
-    return permitted.asMap();
-  }
-
-  private Map<String, Short> currentLabels(Account.Id accountId, ChangeData cd)
-      throws OrmException {
-    Map<String, Short> result = new HashMap<>();
-    for (PatchSetApproval psa :
-        approvalsUtil.byPatchSetUser(
-            db.get(),
-            lazyLoad ? cd.notes() : notesFactory.createFromIndexedChange(cd.change()),
-            cd.change().currentPatchSetId(),
-            accountId,
-            null,
-            null)) {
-      result.put(psa.getLabel(), psa.getValue());
-    }
-    return result;
-  }
-
   private Collection<ChangeMessageInfo> messages(ChangeData cd) throws OrmException {
     List<ChangeMessage> messages = cmUtil.byChange(db.get(), cd.notes());
     if (messages.isEmpty()) {
@@ -1285,47 +781,6 @@
         .collect(toList());
   }
 
-  @Nullable
-  private Repository openRepoIfNecessary(Project.NameKey project) throws IOException {
-    if (has(ALL_COMMITS) || has(CURRENT_COMMIT) || has(COMMIT_FOOTERS)) {
-      return repoManager.openRepository(project);
-    }
-    return null;
-  }
-
-  @Nullable
-  private RevWalk newRevWalk(@Nullable Repository repo) {
-    return repo != null ? new RevWalk(repo) : null;
-  }
-
-  private Map<String, RevisionInfo> revisions(
-      ChangeData cd,
-      Map<PatchSet.Id, PatchSet> map,
-      Optional<PatchSet.Id> limitToPsId,
-      ChangeInfo changeInfo)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
-          PermissionBackendException {
-    Map<String, RevisionInfo> res = new LinkedHashMap<>();
-    try (Repository repo = openRepoIfNecessary(cd.project());
-        RevWalk rw = newRevWalk(repo)) {
-      for (PatchSet in : map.values()) {
-        PatchSet.Id id = in.getId();
-        boolean want;
-        if (has(ALL_REVISIONS)) {
-          want = true;
-        } else if (limitToPsId.isPresent()) {
-          want = id.equals(limitToPsId.get());
-        } else {
-          want = id.equals(cd.change().currentPatchSetId());
-        }
-        if (want) {
-          res.put(in.getRevision().get(), toRevisionInfo(cd, in, repo, rw, false, changeInfo));
-        }
-      }
-      return res;
-    }
-  }
-
   private Map<PatchSet.Id, PatchSet> loadPatchSets(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
       throws OrmException {
     Collection<PatchSet> src;
@@ -1353,197 +808,11 @@
     return map;
   }
 
-  public RevisionInfo getRevisionInfo(ChangeData cd, PatchSet in)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
-          PermissionBackendException {
-    accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-    try (Repository repo = openRepoIfNecessary(cd.project());
-        RevWalk rw = newRevWalk(repo)) {
-      RevisionInfo rev = toRevisionInfo(cd, in, repo, rw, true, null);
-      accountLoader.fill();
-      return rev;
-    }
-  }
-
-  private RevisionInfo toRevisionInfo(
-      ChangeData cd,
-      PatchSet in,
-      @Nullable Repository repo,
-      @Nullable RevWalk rw,
-      boolean fillCommit,
-      @Nullable ChangeInfo changeInfo)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
-          PermissionBackendException {
-    Change c = cd.change();
-    RevisionInfo out = new RevisionInfo();
-    out.isCurrent = in.getId().equals(c.currentPatchSetId());
-    out._number = in.getId().get();
-    out.ref = in.getRefName();
-    out.created = in.getCreatedOn();
-    out.uploader = accountLoader.get(in.getUploader());
-    out.fetch = makeFetchMap(cd, in);
-    out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
-    out.description = in.getDescription();
-
-    boolean setCommit = has(ALL_COMMITS) || (out.isCurrent && has(CURRENT_COMMIT));
-    boolean addFooters = out.isCurrent && has(COMMIT_FOOTERS);
-    if (setCommit || addFooters) {
-      checkState(rw != null);
-      checkState(repo != null);
-      Project.NameKey project = c.getProject();
-      String rev = in.getRevision().get();
-      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
-      rw.parseBody(commit);
-      if (setCommit) {
-        out.commit = toCommit(project, rw, commit, has(WEB_LINKS), fillCommit);
-      }
-      if (addFooters) {
-        Ref ref = repo.exactRef(cd.change().getDest().get());
-        RevCommit mergeTip = null;
-        if (ref != null) {
-          mergeTip = rw.parseCommit(ref.getObjectId());
-          rw.parseBody(mergeTip);
-        }
-        out.commitWithFooters =
-            mergeUtilFactory
-                .create(projectCache.get(project))
-                .createCommitMessageOnSubmit(commit, mergeTip, cd.notes(), in.getId());
-      }
-    }
-
-    if (has(ALL_FILES) || (out.isCurrent && has(CURRENT_FILES))) {
-      out.files = fileInfoJson.toFileInfoMap(c, in);
-      out.files.remove(Patch.COMMIT_MSG);
-      out.files.remove(Patch.MERGE_LIST);
-    }
-
-    if (out.isCurrent && has(CURRENT_ACTIONS) && userProvider.get().isIdentifiedUser()) {
-
-      actionJson.addRevisionActions(
-          changeInfo,
-          out,
-          new RevisionResource(changeResourceFactory.create(cd.notes(), userProvider.get()), in));
-    }
-
-    if (gpgApi.isEnabled() && has(PUSH_CERTIFICATES)) {
-      if (in.getPushCertificate() != null) {
-        out.pushCertificate =
-            gpgApi.checkPushCertificate(
-                in.getPushCertificate(), userFactory.create(in.getUploader()));
-      } else {
-        out.pushCertificate = new PushCertificateInfo();
-      }
-    }
-
-    return out;
-  }
-
-  public CommitInfo toCommit(
-      Project.NameKey project, RevWalk rw, RevCommit commit, boolean addLinks, boolean fillCommit)
-      throws IOException {
-    CommitInfo info = new CommitInfo();
-    if (fillCommit) {
-      info.commit = commit.name();
-    }
-    info.parents = new ArrayList<>(commit.getParentCount());
-    info.author = toGitPerson(commit.getAuthorIdent());
-    info.committer = toGitPerson(commit.getCommitterIdent());
-    info.subject = commit.getShortMessage();
-    info.message = commit.getFullMessage();
-
-    if (addLinks) {
-      List<WebLinkInfo> links = webLinks.getPatchSetLinks(project, commit.name());
-      info.webLinks = links.isEmpty() ? null : links;
-    }
-
-    for (RevCommit parent : commit.getParents()) {
-      rw.parseBody(parent);
-      CommitInfo i = new CommitInfo();
-      i.commit = parent.name();
-      i.subject = parent.getShortMessage();
-      if (addLinks) {
-        List<WebLinkInfo> parentLinks = webLinks.getParentLinks(project, parent.name());
-        i.webLinks = parentLinks.isEmpty() ? null : parentLinks;
-      }
-      info.parents.add(i);
-    }
-    return info;
-  }
-
-  private Map<String, FetchInfo> makeFetchMap(ChangeData cd, PatchSet in)
-      throws PermissionBackendException, OrmException, IOException {
-    Map<String, FetchInfo> r = new LinkedHashMap<>();
-    for (Extension<DownloadScheme> e : downloadSchemes) {
-      String schemeName = e.getExportName();
-      DownloadScheme scheme = e.getProvider().get();
-      if (!scheme.isEnabled()
-          || (scheme.isAuthRequired() && !userProvider.get().isIdentifiedUser())) {
-        continue;
-      }
-      if (!scheme.isAuthSupported() && !isWorldReadable(cd)) {
-        continue;
-      }
-
-      String projectName = cd.project().get();
-      String url = scheme.getUrl(projectName);
-      String refName = in.getRefName();
-      FetchInfo fetchInfo = new FetchInfo(url, refName);
-      r.put(schemeName, fetchInfo);
-
-      if (has(DOWNLOAD_COMMANDS)) {
-        populateFetchMap(scheme, downloadCommands, projectName, refName, fetchInfo);
-      }
-    }
-
-    return r;
-  }
-
-  public static void populateFetchMap(
-      DownloadScheme scheme,
-      DynamicMap<DownloadCommand> commands,
-      String projectName,
-      String refName,
-      FetchInfo fetchInfo) {
-    for (Extension<DownloadCommand> e2 : commands) {
-      String commandName = e2.getExportName();
-      DownloadCommand command = e2.getProvider().get();
-      String c = command.getCommand(scheme, projectName, refName);
-      if (c != null) {
-        addCommand(fetchInfo, commandName, c);
-      }
-    }
-  }
-
-  private static void addCommand(FetchInfo fetchInfo, String commandName, String c) {
-    if (fetchInfo.commands == null) {
-      fetchInfo.commands = new TreeMap<>();
-    }
-    fetchInfo.commands.put(commandName, c);
-  }
-
-  static void finish(ChangeInfo info) {
-    info.id =
-        Joiner.on('~')
-            .join(Url.encode(info.project), Url.encode(info.branch), Url.encode(info.changeId));
-  }
-
-  private static void addApproval(LabelInfo label, ApprovalInfo approval) {
-    if (label.all == null) {
-      label.all = new ArrayList<>();
-    }
-    label.all.add(approval);
-  }
-
   private PermissionBackend.ForChange permissionBackendForChange(CurrentUser user, ChangeData cd)
       throws OrmException {
     return permissionBackendForChange(permissionBackend.user(user).database(db), cd);
   }
 
-  private PermissionBackend.ForChange permissionBackendForChange(Account.Id user, ChangeData cd)
-      throws OrmException {
-    return permissionBackendForChange(permissionBackend.absentUser(user).database(db), cd);
-  }
-
   /**
    * @return {@link com.google.gerrit.server.permissions.PermissionBackend.ForChange} constructed
    *     from either an index-backed or a database-backed {@link ChangeData} depending on {@code
@@ -1555,31 +824,4 @@
         ? withUser.change(cd)
         : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change()));
   }
-
-  private boolean isWorldReadable(ChangeData cd)
-      throws OrmException, PermissionBackendException, IOException {
-    try {
-      permissionBackendForChange(anonymous, cd).check(ChangePermission.READ);
-    } catch (AuthException ae) {
-      return false;
-    }
-    ProjectState projectState = projectCache.checkedGet(cd.project());
-    if (projectState == null) {
-      logger.atSevere().log("project state for project %s is null", cd.project());
-      return false;
-    }
-    return projectState.statePermitsRead();
-  }
-
-  @AutoValue
-  abstract static class LabelWithStatus {
-    private static LabelWithStatus create(LabelInfo label, SubmitRecord.Label.Status status) {
-      return new AutoValue_ChangeJson_LabelWithStatus(label, status);
-    }
-
-    abstract LabelInfo label();
-
-    @Nullable
-    abstract SubmitRecord.Label.Status status();
-  }
 }
diff --git a/java/com/google/gerrit/server/change/DownloadCommandsJson.java b/java/com/google/gerrit/server/change/DownloadCommandsJson.java
new file mode 100644
index 0000000..f56a16c
--- /dev/null
+++ b/java/com/google/gerrit/server/change/DownloadCommandsJson.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.common.FetchInfo;
+import com.google.gerrit.extensions.config.DownloadCommand;
+import com.google.gerrit.extensions.config.DownloadScheme;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.Extension;
+import java.util.TreeMap;
+
+/** Populates the {@link FetchInfo} which is serialized to JSON afterwards. */
+public class DownloadCommandsJson {
+
+  private DownloadCommandsJson() {}
+
+  /**
+   * Populates the provided {@link FetchInfo} by calling all {@link DownloadCommand} extensions.
+   * Will mutate {@link FetchInfo#commands}.
+   */
+  public static void populateFetchMap(
+      DownloadScheme scheme,
+      DynamicMap<DownloadCommand> commands,
+      String projectName,
+      String refName,
+      FetchInfo fetchInfo) {
+    for (Extension<DownloadCommand> ext : commands) {
+      String commandName = ext.getExportName();
+      DownloadCommand command = ext.getProvider().get();
+      String c = command.getCommand(scheme, projectName, refName);
+      if (c != null) {
+        if (fetchInfo.commands == null) {
+          fetchInfo.commands = new TreeMap<>();
+        }
+        fetchInfo.commands.put(commandName, c);
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
new file mode 100644
index 0000000..17e45a9
--- /dev/null
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -0,0 +1,557 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.LinkedHashMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Table;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.VotingRangeInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Account.Id;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import javax.inject.Inject;
+
+/**
+ * Produces label-related entities, like {@link LabelInfo}s, which is serialized to JSON afterwards.
+ */
+public class LabelsJson {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    LabelsJson create(Iterable<ListChangesOption> options);
+  }
+
+  private final Provider<ReviewDb> db;
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeNotes.Factory notesFactory;
+  private final PermissionBackend permissionBackend;
+  private final boolean lazyLoad;
+
+  @Inject
+  LabelsJson(
+      Provider<ReviewDb> db,
+      ApprovalsUtil approvalsUtil,
+      ChangeNotes.Factory notesFactory,
+      PermissionBackend permissionBackend,
+      @Assisted Iterable<ListChangesOption> options) {
+    this.db = db;
+    this.approvalsUtil = approvalsUtil;
+    this.notesFactory = notesFactory;
+    this.permissionBackend = permissionBackend;
+    this.lazyLoad = containsAnyOf(Sets.immutableEnumSet(options), ChangeJson.REQUIRE_LAZY_LOAD);
+  }
+
+  /**
+   * Returns all {@link LabelInfo}s for a single change. Uses the provided {@link AccountLoader} to
+   * lazily populate accounts. Callers have to call {@link AccountLoader#fill()} afterwards to
+   * populate all accounts in the returned {@link LabelInfo}s.
+   */
+  Map<String, LabelInfo> labelsFor(
+      AccountLoader accountLoader, ChangeData cd, boolean standard, boolean detailed)
+      throws OrmException, PermissionBackendException {
+    if (!standard && !detailed) {
+      return null;
+    }
+
+    LabelTypes labelTypes = cd.getLabelTypes();
+    Map<String, LabelWithStatus> withStatus =
+        cd.change().getStatus() == Change.Status.MERGED
+            ? labelsForSubmittedChange(accountLoader, cd, labelTypes, standard, detailed)
+            : labelsForUnsubmittedChange(accountLoader, cd, labelTypes, standard, detailed);
+    return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
+  }
+
+  /** Returns all labels that the provided user has permission to vote on. */
+  Map<String, Collection<String>> permittedLabels(Account.Id filterApprovalsBy, ChangeData cd)
+      throws OrmException, PermissionBackendException {
+    boolean isMerged = cd.change().getStatus() == Change.Status.MERGED;
+    LabelTypes labelTypes = cd.getLabelTypes();
+    Map<String, LabelType> toCheck = new HashMap<>();
+    for (SubmitRecord rec : submitRecords(cd)) {
+      if (rec.labels != null) {
+        for (SubmitRecord.Label r : rec.labels) {
+          LabelType type = labelTypes.byLabel(r.label);
+          if (type != null && (!isMerged || type.allowPostSubmit())) {
+            toCheck.put(type.getName(), type);
+          }
+        }
+      }
+    }
+
+    Map<String, Short> labels = null;
+    Set<LabelPermission.WithValue> can =
+        permissionBackendForChange(filterApprovalsBy, cd).testLabels(toCheck.values());
+    SetMultimap<String, String> permitted = LinkedHashMultimap.create();
+    for (SubmitRecord rec : submitRecords(cd)) {
+      if (rec.labels == null) {
+        continue;
+      }
+      for (SubmitRecord.Label r : rec.labels) {
+        LabelType type = labelTypes.byLabel(r.label);
+        if (type == null || (isMerged && !type.allowPostSubmit())) {
+          continue;
+        }
+
+        for (LabelValue v : type.getValues()) {
+          boolean ok = can.contains(new LabelPermission.WithValue(type, v));
+          if (isMerged) {
+            if (labels == null) {
+              labels = currentLabels(filterApprovalsBy, cd);
+            }
+            short prev = labels.getOrDefault(type.getName(), (short) 0);
+            ok &= v.getValue() >= prev;
+          }
+          if (ok) {
+            permitted.put(r.label, v.formatValue());
+          }
+        }
+      }
+    }
+
+    List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
+    for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
+      if (isOnlyZero(e.getValue())) {
+        toClear.add(e.getKey());
+      }
+    }
+    for (String label : toClear) {
+      permitted.removeAll(label);
+    }
+    return permitted.asMap();
+  }
+
+  private static boolean containsAnyOf(
+      ImmutableSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
+    return !Sets.intersection(toFind, set).isEmpty();
+  }
+
+  private static boolean isOnlyZero(Collection<String> values) {
+    return values.isEmpty() || (values.size() == 1 && values.contains(" 0"));
+  }
+
+  private static void addApproval(LabelInfo label, ApprovalInfo approval) {
+    if (label.all == null) {
+      label.all = new ArrayList<>();
+    }
+    label.all.add(approval);
+  }
+
+  private Map<String, LabelWithStatus> labelsForUnsubmittedChange(
+      AccountLoader accountLoader,
+      ChangeData cd,
+      LabelTypes labelTypes,
+      boolean standard,
+      boolean detailed)
+      throws OrmException, PermissionBackendException {
+    Map<String, LabelWithStatus> labels = initLabels(accountLoader, cd, labelTypes, standard);
+    if (detailed) {
+      setAllApprovals(accountLoader, cd, labels);
+    }
+    for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
+      LabelType type = labelTypes.byLabel(e.getKey());
+      if (type == null) {
+        continue;
+      }
+      if (standard) {
+        for (PatchSetApproval psa : cd.currentApprovals()) {
+          if (type.matches(psa)) {
+            short val = psa.getValue();
+            Account.Id accountId = psa.getAccountId();
+            setLabelScores(accountLoader, type, e.getValue(), val, accountId);
+          }
+        }
+      }
+      if (detailed) {
+        setLabelValues(type, e.getValue());
+      }
+    }
+    return labels;
+  }
+
+  private Integer parseRangeValue(String value) {
+    if (value.startsWith("+")) {
+      value = value.substring(1);
+    } else if (value.startsWith(" ")) {
+      value = value.trim();
+    }
+    return Ints.tryParse(value);
+  }
+
+  private ApprovalInfo approvalInfo(
+      AccountLoader accountLoader,
+      Account.Id id,
+      Integer value,
+      VotingRangeInfo permittedVotingRange,
+      String tag,
+      Timestamp date) {
+    ApprovalInfo ai = ChangeJson.getApprovalInfo(id, value, permittedVotingRange, tag, date);
+    accountLoader.put(ai);
+    return ai;
+  }
+
+  private void setLabelValues(LabelType type, LabelWithStatus l) {
+    l.label().defaultValue = type.getDefaultValue();
+    l.label().values = new LinkedHashMap<>();
+    for (LabelValue v : type.getValues()) {
+      l.label().values.put(v.formatValue(), v.getText());
+    }
+    if (isOnlyZero(l.label().values.keySet())) {
+      l.label().values = null;
+    }
+  }
+
+  private Map<String, Short> currentLabels(Account.Id accountId, ChangeData cd)
+      throws OrmException {
+    Map<String, Short> result = new HashMap<>();
+    for (PatchSetApproval psa :
+        approvalsUtil.byPatchSetUser(
+            db.get(),
+            lazyLoad ? cd.notes() : notesFactory.createFromIndexedChange(cd.change()),
+            cd.change().currentPatchSetId(),
+            accountId,
+            null,
+            null)) {
+      result.put(psa.getLabel(), psa.getValue());
+    }
+    return result;
+  }
+
+  private Map<String, LabelWithStatus> labelsForSubmittedChange(
+      AccountLoader accountLoader,
+      ChangeData cd,
+      LabelTypes labelTypes,
+      boolean standard,
+      boolean detailed)
+      throws OrmException, PermissionBackendException {
+    Set<Account.Id> allUsers = new HashSet<>();
+    if (detailed) {
+      // Users expect to see all reviewers on closed changes, even if they
+      // didn't vote on the latest patch set. If we don't need detailed labels,
+      // we aren't including 0 votes for all users below, so we can just look at
+      // the latest patch set (in the next loop).
+      for (PatchSetApproval psa : cd.approvals().values()) {
+        allUsers.add(psa.getAccountId());
+      }
+    }
+
+    Set<String> labelNames = new HashSet<>();
+    SetMultimap<Id, PatchSetApproval> current = MultimapBuilder.hashKeys().hashSetValues().build();
+    for (PatchSetApproval a : cd.currentApprovals()) {
+      allUsers.add(a.getAccountId());
+      LabelType type = labelTypes.byLabel(a.getLabelId());
+      if (type != null) {
+        labelNames.add(type.getName());
+        // Not worth the effort to distinguish between votable/non-votable for 0
+        // values on closed changes, since they can't vote anyway.
+        current.put(a.getAccountId(), a);
+      }
+    }
+
+    // Since voting on merged changes is allowed all labels which apply to
+    // the change must be returned. All applying labels can be retrieved from
+    // the submit records, which is what initLabels does.
+    // It's not possible to only compute the labels based on the approvals
+    // since merged changes may not have approvals for all labels (e.g. if not
+    // all labels are required for submit or if the change was auto-closed due
+    // to direct push or if new labels were defined after the change was
+    // merged).
+    Map<String, LabelWithStatus> labels;
+    labels = initLabels(accountLoader, cd, labelTypes, standard);
+
+    // Also include all labels for which approvals exists. E.g. there can be
+    // approvals for labels that are ignored by a Prolog submit rule and hence
+    // it wouldn't be included in the submit records.
+    for (String name : labelNames) {
+      if (!labels.containsKey(name)) {
+        labels.put(name, LabelWithStatus.create(new LabelInfo(), null));
+      }
+    }
+
+    if (detailed) {
+      labels
+          .entrySet()
+          .stream()
+          .filter(e -> labelTypes.byLabel(e.getKey()) != null)
+          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()), e.getValue()));
+    }
+
+    for (Account.Id accountId : allUsers) {
+      Map<String, ApprovalInfo> byLabel = Maps.newHashMapWithExpectedSize(labels.size());
+      Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
+      if (detailed) {
+        pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
+        for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
+          ApprovalInfo ai = approvalInfo(accountLoader, accountId, 0, null, null, null);
+          byLabel.put(entry.getKey(), ai);
+          addApproval(entry.getValue().label(), ai);
+        }
+      }
+      for (PatchSetApproval psa : current.get(accountId)) {
+        LabelType type = labelTypes.byLabel(psa.getLabelId());
+        if (type == null) {
+          continue;
+        }
+
+        short val = psa.getValue();
+        ApprovalInfo info = byLabel.get(type.getName());
+        if (info != null) {
+          info.value = Integer.valueOf(val);
+          info.permittedVotingRange = pvr.getOrDefault(type.getName(), null);
+          info.date = psa.getGranted();
+          info.tag = psa.getTag();
+          if (psa.isPostSubmit()) {
+            info.postSubmit = true;
+          }
+        }
+        if (!standard) {
+          continue;
+        }
+
+        setLabelScores(accountLoader, type, labels.get(type.getName()), val, accountId);
+      }
+    }
+    return labels;
+  }
+
+  private Map<String, LabelWithStatus> initLabels(
+      AccountLoader accountLoader, ChangeData cd, LabelTypes labelTypes, boolean standard) {
+    Map<String, LabelWithStatus> labels = new TreeMap<>(labelTypes.nameComparator());
+    for (SubmitRecord rec : submitRecords(cd)) {
+      if (rec.labels == null) {
+        continue;
+      }
+      for (SubmitRecord.Label r : rec.labels) {
+        LabelWithStatus p = labels.get(r.label);
+        if (p == null || p.status().compareTo(r.status) < 0) {
+          LabelInfo n = new LabelInfo();
+          if (standard) {
+            switch (r.status) {
+              case OK:
+                n.approved = accountLoader.get(r.appliedBy);
+                break;
+              case REJECT:
+                n.rejected = accountLoader.get(r.appliedBy);
+                n.blocking = true;
+                break;
+              case IMPOSSIBLE:
+              case MAY:
+              case NEED:
+              default:
+                break;
+            }
+          }
+
+          n.optional = r.status == SubmitRecord.Label.Status.MAY ? true : null;
+          labels.put(r.label, LabelWithStatus.create(n, r.status));
+        }
+      }
+    }
+    return labels;
+  }
+
+  private void setLabelScores(
+      AccountLoader accountLoader,
+      LabelType type,
+      LabelWithStatus l,
+      short score,
+      Account.Id accountId) {
+    if (l.label().approved != null || l.label().rejected != null) {
+      return;
+    }
+
+    if (type.getMin() == null || type.getMax() == null) {
+      // Can't set score for unknown or misconfigured type.
+      return;
+    }
+
+    if (score != 0) {
+      if (score == type.getMin().getValue()) {
+        l.label().rejected = accountLoader.get(accountId);
+      } else if (score == type.getMax().getValue()) {
+        l.label().approved = accountLoader.get(accountId);
+      } else if (score < 0) {
+        l.label().disliked = accountLoader.get(accountId);
+        l.label().value = score;
+      } else if (score > 0 && l.label().disliked == null) {
+        l.label().recommended = accountLoader.get(accountId);
+        l.label().value = score;
+      }
+    }
+  }
+
+  private void setAllApprovals(
+      AccountLoader accountLoader, ChangeData cd, Map<String, LabelWithStatus> labels)
+      throws OrmException, PermissionBackendException {
+    Change.Status status = cd.change().getStatus();
+    checkState(
+        status != Change.Status.MERGED, "should not call setAllApprovals on %s change", status);
+
+    // Include a user in the output for this label if either:
+    //  - They are an explicit reviewer.
+    //  - They ever voted on this change.
+    Set<Id> allUsers = new HashSet<>();
+    allUsers.addAll(cd.reviewers().byState(ReviewerStateInternal.REVIEWER));
+    for (PatchSetApproval psa : cd.approvals().values()) {
+      allUsers.add(psa.getAccountId());
+    }
+
+    Table<Id, String, PatchSetApproval> current =
+        HashBasedTable.create(allUsers.size(), cd.getLabelTypes().getLabelTypes().size());
+    for (PatchSetApproval psa : cd.currentApprovals()) {
+      current.put(psa.getAccountId(), psa.getLabel(), psa);
+    }
+
+    LabelTypes labelTypes = cd.getLabelTypes();
+    for (Account.Id accountId : allUsers) {
+      PermissionBackend.ForChange perm = permissionBackendForChange(accountId, cd);
+      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
+      for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
+        LabelType lt = labelTypes.byLabel(e.getKey());
+        if (lt == null) {
+          // Ignore submit record for undefined label; likely the submit rule
+          // author didn't intend for the label to show up in the table.
+          continue;
+        }
+        Integer value;
+        VotingRangeInfo permittedVotingRange = pvr.getOrDefault(lt.getName(), null);
+        String tag = null;
+        Timestamp date = null;
+        PatchSetApproval psa = current.get(accountId, lt.getName());
+        if (psa != null) {
+          value = Integer.valueOf(psa.getValue());
+          if (value == 0) {
+            // This may be a dummy approval that was inserted when the reviewer
+            // was added. Explicitly check whether the user can vote on this
+            // label.
+            value = perm.test(new LabelPermission(lt)) ? 0 : null;
+          }
+          tag = psa.getTag();
+          date = psa.getGranted();
+          if (psa.isPostSubmit()) {
+            logger.atWarning().log("unexpected post-submit approval on open change: %s", psa);
+          }
+        } else {
+          // Either the user cannot vote on this label, or they were added as a
+          // reviewer but have not responded yet. Explicitly check whether the
+          // user can vote on this label.
+          value = perm.test(new LabelPermission(lt)) ? 0 : null;
+        }
+        addApproval(
+            e.getValue().label(),
+            approvalInfo(accountLoader, accountId, value, permittedVotingRange, tag, date));
+      }
+    }
+  }
+
+  /**
+   * @return {@link com.google.gerrit.server.permissions.PermissionBackend.ForChange} constructed
+   *     from either an index-backed or a database-backed {@link ChangeData} depending on {@code
+   *     lazyload}.
+   */
+  private PermissionBackend.ForChange permissionBackendForChange(Account.Id user, ChangeData cd)
+      throws OrmException {
+    PermissionBackend.WithUser withUser = permissionBackend.absentUser(user).database(db);
+    return lazyLoad
+        ? withUser.change(cd)
+        : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change()));
+  }
+
+  private List<SubmitRecord> submitRecords(ChangeData cd) {
+    return cd.submitRecords(ChangeJson.SUBMIT_RULE_OPTIONS_LENIENT);
+  }
+
+  private Map<String, VotingRangeInfo> getPermittedVotingRanges(
+      Map<String, Collection<String>> permittedLabels) {
+    Map<String, VotingRangeInfo> permittedVotingRanges =
+        Maps.newHashMapWithExpectedSize(permittedLabels.size());
+    for (String label : permittedLabels.keySet()) {
+      List<Integer> permittedVotingRange =
+          permittedLabels
+              .get(label)
+              .stream()
+              .map(this::parseRangeValue)
+              .filter(java.util.Objects::nonNull)
+              .sorted()
+              .collect(toList());
+
+      if (permittedVotingRange.isEmpty()) {
+        permittedVotingRanges.put(label, null);
+      } else {
+        int minPermittedValue = permittedVotingRange.get(0);
+        int maxPermittedValue = Iterables.getLast(permittedVotingRange);
+        permittedVotingRanges.put(label, new VotingRangeInfo(minPermittedValue, maxPermittedValue));
+      }
+    }
+    return permittedVotingRanges;
+  }
+
+  @AutoValue
+  abstract static class LabelWithStatus {
+    private static LabelWithStatus create(LabelInfo label, SubmitRecord.Label.Status status) {
+      return new AutoValue_LabelsJson_LabelWithStatus(label, status);
+    }
+
+    abstract LabelInfo label();
+
+    @Nullable
+    abstract SubmitRecord.Label.Status status();
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerAdder.java
new file mode 100644
index 0000000..f2be90d
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ReviewerAdder.java
@@ -0,0 +1,473 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.GroupMembers;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.GroupResolver;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NotesMigration;
+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.project.ProjectCache;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+public class ReviewerAdder {
+  public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
+  public static final int DEFAULT_MAX_REVIEWERS = 20;
+
+  private final AccountResolver accountResolver;
+  private final PermissionBackend permissionBackend;
+  private final GroupResolver groupResolver;
+  private final GroupMembers groupMembers;
+  private final AccountLoader.Factory accountLoaderFactory;
+  private final Provider<ReviewDb> dbProvider;
+  private final Config cfg;
+  private final ReviewerJson json;
+  private final NotesMigration migration;
+  private final NotifyUtil notifyUtil;
+  private final ProjectCache projectCache;
+  private final Provider<AnonymousUser> anonymousProvider;
+  private final AddReviewersOp.Factory addReviewersOpFactory;
+  private final OutgoingEmailValidator validator;
+
+  @Inject
+  ReviewerAdder(
+      AccountResolver accountResolver,
+      PermissionBackend permissionBackend,
+      GroupResolver groupResolver,
+      GroupMembers groupMembers,
+      AccountLoader.Factory accountLoaderFactory,
+      Provider<ReviewDb> db,
+      @GerritServerConfig Config cfg,
+      ReviewerJson json,
+      NotesMigration migration,
+      NotifyUtil notifyUtil,
+      ProjectCache projectCache,
+      Provider<AnonymousUser> anonymousProvider,
+      AddReviewersOp.Factory addReviewersOpFactory,
+      OutgoingEmailValidator validator) {
+    this.accountResolver = accountResolver;
+    this.permissionBackend = permissionBackend;
+    this.groupResolver = groupResolver;
+    this.groupMembers = groupMembers;
+    this.accountLoaderFactory = accountLoaderFactory;
+    this.dbProvider = db;
+    this.cfg = cfg;
+    this.json = json;
+    this.migration = migration;
+    this.notifyUtil = notifyUtil;
+    this.projectCache = projectCache;
+    this.anonymousProvider = anonymousProvider;
+    this.addReviewersOpFactory = addReviewersOpFactory;
+    this.validator = validator;
+  }
+
+  /**
+   * Prepare application of a single {@link AddReviewerInput}.
+   *
+   * @param notes change notes.
+   * @param user user performing the reviewer addition.
+   * @param input input describing user or group to add as a reviewer.
+   * @param allowGroup whether to allow
+   * @return handle describing the addition operation. If the {@code op} field is present, this
+   *     operation may be added to a {@code BatchUpdate}. Otherwise, the {@code error} field
+   *     contains information about an error that occurred
+   * @throws OrmException
+   * @throws IOException
+   * @throws PermissionBackendException
+   * @throws ConfigInvalidException
+   */
+  public ReviewerAddition prepare(
+      ChangeNotes notes, CurrentUser user, AddReviewerInput input, boolean allowGroup)
+      throws OrmException, IOException, PermissionBackendException, ConfigInvalidException {
+    Branch.NameKey dest = notes.getChange().getDest();
+    String reviewer = checkNotNull(input.reviewer);
+    ReviewerState state = input.state();
+    NotifyHandling notify = input.notify;
+    ListMultimap<RecipientType, Account.Id> accountsToNotify;
+    try {
+      accountsToNotify = notifyUtil.resolveAccounts(input.notifyDetails);
+    } catch (BadRequestException e) {
+      return fail(reviewer, e.getMessage());
+    }
+    boolean confirmed = input.confirmed();
+    boolean allowByEmail =
+        projectCache
+            .checkedGet(dest.getParentKey())
+            .is(BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL);
+
+    ReviewerAddition byAccountId =
+        addByAccountId(
+            reviewer, dest, user, state, notify, accountsToNotify, allowGroup, allowByEmail);
+
+    ReviewerAddition wholeGroup = null;
+    if (byAccountId == null || !byAccountId.exactMatchFound) {
+      wholeGroup =
+          addWholeGroup(
+              reviewer,
+              dest,
+              user,
+              state,
+              notify,
+              accountsToNotify,
+              confirmed,
+              allowGroup,
+              allowByEmail);
+      if (wholeGroup != null && wholeGroup.exactMatchFound) {
+        return wholeGroup;
+      }
+    }
+
+    if (byAccountId != null) {
+      return byAccountId;
+    }
+    if (wholeGroup != null) {
+      return wholeGroup;
+    }
+
+    return addByEmail(reviewer, notes, user, state, notify, accountsToNotify);
+  }
+
+  public ReviewerAddition ccCurrentUser(CurrentUser user, RevisionResource revision) {
+    return new ReviewerAddition(
+        user.getUserName().orElse(null),
+        revision.getUser(),
+        ImmutableSet.of(user.getAccountId()),
+        null,
+        CC,
+        NotifyHandling.NONE,
+        ImmutableListMultimap.of(),
+        true);
+  }
+
+  @Nullable
+  private ReviewerAddition addByAccountId(
+      String reviewer,
+      Branch.NameKey dest,
+      CurrentUser user,
+      ReviewerState state,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify,
+      boolean allowGroup,
+      boolean allowByEmail)
+      throws OrmException, PermissionBackendException, IOException, ConfigInvalidException {
+    IdentifiedUser reviewerUser;
+    boolean exactMatchFound = false;
+    try {
+      reviewerUser = accountResolver.parse(reviewer);
+      if (reviewer.equalsIgnoreCase(reviewerUser.getName())
+          || reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) {
+        exactMatchFound = true;
+      }
+    } catch (UnprocessableEntityException | AuthException e) {
+      // AuthException won't occur since the user is authenticated at this point.
+      if (!allowGroup && !allowByEmail) {
+        // Only return failure if we aren't going to try other interpretations.
+        return fail(
+            reviewer, MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, reviewer));
+      }
+      return null;
+    }
+
+    if (isValidReviewer(dest, reviewerUser.getAccount())) {
+      return new ReviewerAddition(
+          reviewer,
+          user,
+          ImmutableSet.of(reviewerUser.getAccountId()),
+          null,
+          state,
+          notify,
+          accountsToNotify,
+          exactMatchFound);
+    }
+    if (!reviewerUser.getAccount().isActive()) {
+      if (allowByEmail && state == CC) {
+        return null;
+      }
+      return fail(reviewer, MessageFormat.format(ChangeMessages.get().reviewerInactive, reviewer));
+    }
+    return fail(
+        reviewer, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, reviewer));
+  }
+
+  @Nullable
+  private ReviewerAddition addWholeGroup(
+      String reviewer,
+      Branch.NameKey dest,
+      CurrentUser user,
+      ReviewerState state,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify,
+      boolean confirmed,
+      boolean allowGroup,
+      boolean allowByEmail)
+      throws IOException, PermissionBackendException {
+    if (!allowGroup) {
+      return null;
+    }
+
+    GroupDescription.Basic group;
+    try {
+      group = groupResolver.parseInternal(reviewer);
+    } catch (UnprocessableEntityException e) {
+      if (!allowByEmail) {
+        return fail(
+            reviewer,
+            MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, reviewer));
+      }
+      return null;
+    }
+
+    if (!isLegalReviewerGroup(group.getGroupUUID())) {
+      return fail(
+          reviewer, MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
+    }
+
+    Set<Account.Id> reviewers = new HashSet<>();
+    Set<Account> members;
+    try {
+      members = groupMembers.listAccounts(group.getGroupUUID(), dest.getParentKey());
+    } catch (NoSuchProjectException e) {
+      return fail(reviewer, e.getMessage());
+    }
+
+    // if maxAllowed is set to 0, it is allowed to add any number of
+    // reviewers
+    int maxAllowed = cfg.getInt("addreviewer", "maxAllowed", DEFAULT_MAX_REVIEWERS);
+    if (maxAllowed > 0 && members.size() > maxAllowed) {
+      return fail(
+          reviewer,
+          MessageFormat.format(ChangeMessages.get().groupHasTooManyMembers, group.getName()));
+    }
+
+    // if maxWithoutCheck is set to 0, we never ask for confirmation
+    int maxWithoutConfirmation =
+        cfg.getInt("addreviewer", "maxWithoutConfirmation", DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
+    if (!confirmed && maxWithoutConfirmation > 0 && members.size() > maxWithoutConfirmation) {
+      return fail(
+          reviewer,
+          true,
+          MessageFormat.format(
+              ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size()));
+    }
+
+    for (Account member : members) {
+      if (isValidReviewer(dest, member)) {
+        reviewers.add(member.getId());
+      }
+    }
+
+    return new ReviewerAddition(
+        reviewer, user, reviewers, null, state, notify, accountsToNotify, true);
+  }
+
+  @Nullable
+  private ReviewerAddition addByEmail(
+      String reviewer,
+      ChangeNotes notes,
+      CurrentUser user,
+      ReviewerState state,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify)
+      throws PermissionBackendException {
+    try {
+      permissionBackend
+          .user(anonymousProvider.get())
+          .database(dbProvider)
+          .change(notes)
+          .check(ChangePermission.READ);
+    } catch (AuthException e) {
+      return fail(
+          reviewer, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, reviewer));
+    }
+
+    if (!migration.readChanges()) {
+      // addByEmail depends on NoteDb.
+      return fail(
+          reviewer, MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, reviewer));
+    }
+    Address adr = Address.tryParse(reviewer);
+    if (adr == null || !validator.isValid(adr.getEmail())) {
+      return fail(reviewer, MessageFormat.format(ChangeMessages.get().reviewerInvalid, reviewer));
+    }
+    return new ReviewerAddition(
+        reviewer, user, null, ImmutableList.of(adr), state, notify, accountsToNotify, true);
+  }
+
+  private boolean isValidReviewer(Branch.NameKey branch, Account member)
+      throws PermissionBackendException {
+    if (!member.isActive()) {
+      return false;
+    }
+
+    try {
+      // Check ref permission instead of change permission, since change permissions take into
+      // account the private bit, whereas adding a user as a reviewer is explicitly allowing them to
+      // see private changes.
+      permissionBackend
+          .absentUser(member.getId())
+          .database(dbProvider)
+          .ref(branch)
+          .check(RefPermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
+  }
+
+  private ReviewerAddition fail(String reviewer, String error) {
+    return fail(reviewer, false, error);
+  }
+
+  private ReviewerAddition fail(String reviewer, boolean confirm, String error) {
+    ReviewerAddition addition = new ReviewerAddition(reviewer);
+    addition.result.confirm = confirm ? true : null;
+    addition.result.error = error;
+    return addition;
+  }
+
+  public class ReviewerAddition {
+    public final AddReviewerResult result;
+    @Nullable public final AddReviewersOp op;
+    public final ImmutableSet<Account.Id> reviewers;
+    public final ImmutableSet<Address> reviewersByEmail;
+    public final ReviewerState state;
+    @Nullable final IdentifiedUser caller;
+    final boolean exactMatchFound;
+
+    private ReviewerAddition(String reviewer) {
+      result = new AddReviewerResult(reviewer);
+      op = null;
+      reviewers = ImmutableSet.of();
+      reviewersByEmail = ImmutableSet.of();
+      state = REVIEWER;
+      caller = null;
+      exactMatchFound = false;
+    }
+
+    private ReviewerAddition(
+        String reviewer,
+        CurrentUser caller,
+        @Nullable Iterable<Account.Id> reviewers,
+        @Nullable Iterable<Address> reviewersByEmail,
+        ReviewerState state,
+        @Nullable NotifyHandling notify,
+        ListMultimap<RecipientType, Account.Id> accountsToNotify,
+        boolean exactMatchFound) {
+      checkArgument(
+          reviewers != null || reviewersByEmail != null,
+          "must have either reviewers or reviewersByEmail");
+
+      result = new AddReviewerResult(reviewer);
+      this.reviewers = reviewers == null ? ImmutableSet.of() : ImmutableSet.copyOf(reviewers);
+      this.reviewersByEmail =
+          reviewersByEmail == null ? ImmutableSet.of() : ImmutableSet.copyOf(reviewersByEmail);
+      this.state = state;
+      this.caller = caller.asIdentifiedUser();
+      op =
+          addReviewersOpFactory.create(
+              this.reviewers, this.reviewersByEmail, state, notify, accountsToNotify);
+      this.exactMatchFound = exactMatchFound;
+    }
+
+    public void gatherResults(ChangeData cd) throws OrmException, PermissionBackendException {
+      checkState(op != null, "addition did not result in an update op");
+      checkState(op.getResult() != null, "op did not return a result");
+
+      // Generate result details and fill AccountLoader. This occurs outside
+      // the Op because the accounts are in a different table.
+      AddReviewersOp.Result opResult = op.getResult();
+      if (migration.readChanges() && state == CC) {
+        result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
+        for (Account.Id accountId : opResult.addedCCs()) {
+          result.ccs.add(json.format(new ReviewerInfo(accountId.get()), accountId, cd));
+        }
+        accountLoaderFactory.create(true).fill(result.ccs);
+        for (Address a : opResult.addedCCsByEmail()) {
+          result.ccs.add(new AccountInfo(a.getName(), a.getEmail()));
+        }
+      } else {
+        result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
+        for (PatchSetApproval psa : opResult.addedReviewers()) {
+          // New reviewers have value 0, don't bother normalizing.
+          result.reviewers.add(
+              json.format(
+                  new ReviewerInfo(psa.getAccountId().get()),
+                  psa.getAccountId(),
+                  cd,
+                  ImmutableList.of(psa)));
+        }
+        accountLoaderFactory.create(true).fill(result.reviewers);
+        for (Address a : opResult.addedReviewersByEmail()) {
+          result.reviewers.add(ReviewerInfo.byEmail(a.getName(), a.getEmail()));
+        }
+      }
+    }
+  }
+
+  public static boolean isLegalReviewerGroup(AccountGroup.UUID groupUUID) {
+    return !SystemGroupBackend.isSystemGroup(groupUUID);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
similarity index 97%
rename from java/com/google/gerrit/server/restapi/change/ReviewerJson.java
rename to java/com/google/gerrit/server/change/ReviewerJson.java
index 29c5649..6502569 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.restapi.change;
+package com.google.gerrit.server.change;
 
 import static com.google.gerrit.common.data.LabelValue.formatValue;
 
@@ -29,7 +29,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
new file mode 100644
index 0000000..b67028d
--- /dev/null
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -0,0 +1,393 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_COMMITS;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_FILES;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.COMMIT_FOOTERS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_FILES;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
+import static com.google.gerrit.extensions.client.ListChangesOption.DOWNLOAD_COMMANDS;
+import static com.google.gerrit.extensions.client.ListChangesOption.PUSH_CERTIFICATES;
+import static com.google.gerrit.extensions.client.ListChangesOption.WEB_LINKS;
+import static com.google.gerrit.server.CommonConverters.toGitPerson;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.FetchInfo;
+import com.google.gerrit.extensions.common.PushCertificateInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.config.DownloadCommand;
+import com.google.gerrit.extensions.config.DownloadScheme;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.Extension;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSet.Id;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.WebLinks;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.GpgApiAdapter;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+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.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Produces {@link RevisionInfo} and {@link CommitInfo} which are serialized to JSON afterwards. */
+public class RevisionJson {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    RevisionJson create(Iterable<ListChangesOption> options);
+  }
+
+  private final MergeUtil.Factory mergeUtilFactory;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final FileInfoJson fileInfoJson;
+  private final GpgApiAdapter gpgApi;
+  private final ChangeResource.Factory changeResourceFactory;
+  private final ChangeKindCache changeKindCache;
+  private final ActionJson actionJson;
+  private final DynamicMap<DownloadScheme> downloadSchemes;
+  private final DynamicMap<DownloadCommand> downloadCommands;
+  private final WebLinks webLinks;
+  private final Provider<CurrentUser> userProvider;
+  private final ProjectCache projectCache;
+  private final ImmutableSet<ListChangesOption> options;
+  private final AccountLoader.Factory accountLoaderFactory;
+  private final AnonymousUser anonymous;
+  private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
+  private final ChangeNotes.Factory notesFactory;
+  private final boolean lazyLoad;
+
+  @Inject
+  RevisionJson(
+      Provider<CurrentUser> userProvider,
+      AnonymousUser anonymous,
+      ProjectCache projectCache,
+      IdentifiedUser.GenericFactory userFactory,
+      MergeUtil.Factory mergeUtilFactory,
+      FileInfoJson fileInfoJson,
+      AccountLoader.Factory accountLoaderFactory,
+      DynamicMap<DownloadScheme> downloadSchemes,
+      DynamicMap<DownloadCommand> downloadCommands,
+      WebLinks webLinks,
+      ActionJson actionJson,
+      GpgApiAdapter gpgApi,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeKindCache changeKindCache,
+      GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
+      ChangeNotes.Factory notesFactory,
+      @Assisted Iterable<ListChangesOption> options) {
+    this.userProvider = userProvider;
+    this.anonymous = anonymous;
+    this.projectCache = projectCache;
+    this.userFactory = userFactory;
+    this.mergeUtilFactory = mergeUtilFactory;
+    this.fileInfoJson = fileInfoJson;
+    this.accountLoaderFactory = accountLoaderFactory;
+    this.downloadSchemes = downloadSchemes;
+    this.downloadCommands = downloadCommands;
+    this.webLinks = webLinks;
+    this.actionJson = actionJson;
+    this.gpgApi = gpgApi;
+    this.changeResourceFactory = changeResourceFactory;
+    this.changeKindCache = changeKindCache;
+    this.permissionBackend = permissionBackend;
+    this.notesFactory = notesFactory;
+    this.repoManager = repoManager;
+    this.options = ImmutableSet.copyOf(options);
+    this.lazyLoad = containsAnyOf(this.options, ChangeJson.REQUIRE_LAZY_LOAD);
+  }
+
+  /**
+   * Returns a {@link RevisionInfo} based on a change and patch set. Reads from the repository
+   * depending on the options provided when constructing this instance.
+   */
+  public RevisionInfo getRevisionInfo(ChangeData cd, PatchSet in)
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
+          PermissionBackendException {
+    AccountLoader accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
+    try (Repository repo = openRepoIfNecessary(cd.project());
+        RevWalk rw = newRevWalk(repo)) {
+      RevisionInfo rev = toRevisionInfo(accountLoader, cd, in, repo, rw, true, null);
+      accountLoader.fill();
+      return rev;
+    }
+  }
+
+  /**
+   * Returns a {@link CommitInfo} based on a commit and formatting options. Uses the provided
+   * RevWalk and assumes it is backed by an open repository.
+   */
+  public CommitInfo getCommitInfo(
+      Project.NameKey project, RevWalk rw, RevCommit commit, boolean addLinks, boolean fillCommit)
+      throws IOException {
+    CommitInfo info = new CommitInfo();
+    if (fillCommit) {
+      info.commit = commit.name();
+    }
+    info.parents = new ArrayList<>(commit.getParentCount());
+    info.author = toGitPerson(commit.getAuthorIdent());
+    info.committer = toGitPerson(commit.getCommitterIdent());
+    info.subject = commit.getShortMessage();
+    info.message = commit.getFullMessage();
+
+    if (addLinks) {
+      List<WebLinkInfo> links = webLinks.getPatchSetLinks(project, commit.name());
+      info.webLinks = links.isEmpty() ? null : links;
+    }
+
+    for (RevCommit parent : commit.getParents()) {
+      rw.parseBody(parent);
+      CommitInfo i = new CommitInfo();
+      i.commit = parent.name();
+      i.subject = parent.getShortMessage();
+      if (addLinks) {
+        List<WebLinkInfo> parentLinks = webLinks.getParentLinks(project, parent.name());
+        i.webLinks = parentLinks.isEmpty() ? null : parentLinks;
+      }
+      info.parents.add(i);
+    }
+    return info;
+  }
+
+  /**
+   * Returns multiple {@link RevisionInfo}s for a single change. Uses the provided {@link
+   * AccountLoader} to lazily populate accounts. Callers have to call {@link AccountLoader#fill()}
+   * afterwards to populate all accounts in the returned {@link RevisionInfo}s.
+   */
+  Map<String, RevisionInfo> getRevisions(
+      AccountLoader accountLoader,
+      ChangeData cd,
+      Map<PatchSet.Id, PatchSet> map,
+      Optional<Id> limitToPsId,
+      ChangeInfo changeInfo)
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
+          PermissionBackendException {
+    Map<String, RevisionInfo> res = new LinkedHashMap<>();
+    try (Repository repo = openRepoIfNecessary(cd.project());
+        RevWalk rw = newRevWalk(repo)) {
+      for (PatchSet in : map.values()) {
+        PatchSet.Id id = in.getId();
+        boolean want;
+        if (has(ALL_REVISIONS)) {
+          want = true;
+        } else if (limitToPsId.isPresent()) {
+          want = id.equals(limitToPsId.get());
+        } else {
+          want = id.equals(cd.change().currentPatchSetId());
+        }
+        if (want) {
+          res.put(
+              in.getRevision().get(),
+              toRevisionInfo(accountLoader, cd, in, repo, rw, false, changeInfo));
+        }
+      }
+      return res;
+    }
+  }
+
+  private Map<String, FetchInfo> makeFetchMap(ChangeData cd, PatchSet in)
+      throws PermissionBackendException, OrmException, IOException {
+    Map<String, FetchInfo> r = new LinkedHashMap<>();
+    for (Extension<DownloadScheme> e : downloadSchemes) {
+      String schemeName = e.getExportName();
+      DownloadScheme scheme = e.getProvider().get();
+      if (!scheme.isEnabled()
+          || (scheme.isAuthRequired() && !userProvider.get().isIdentifiedUser())) {
+        continue;
+      }
+      if (!scheme.isAuthSupported() && !isWorldReadable(cd)) {
+        continue;
+      }
+
+      String projectName = cd.project().get();
+      String url = scheme.getUrl(projectName);
+      String refName = in.getRefName();
+      FetchInfo fetchInfo = new FetchInfo(url, refName);
+      r.put(schemeName, fetchInfo);
+
+      if (has(DOWNLOAD_COMMANDS)) {
+        DownloadCommandsJson.populateFetchMap(
+            scheme, downloadCommands, projectName, refName, fetchInfo);
+      }
+    }
+
+    return r;
+  }
+
+  private RevisionInfo toRevisionInfo(
+      AccountLoader accountLoader,
+      ChangeData cd,
+      PatchSet in,
+      @Nullable Repository repo,
+      @Nullable RevWalk rw,
+      boolean fillCommit,
+      @Nullable ChangeInfo changeInfo)
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
+          PermissionBackendException {
+    Change c = cd.change();
+    RevisionInfo out = new RevisionInfo();
+    out.isCurrent = in.getId().equals(c.currentPatchSetId());
+    out._number = in.getId().get();
+    out.ref = in.getRefName();
+    out.created = in.getCreatedOn();
+    out.uploader = accountLoader.get(in.getUploader());
+    out.fetch = makeFetchMap(cd, in);
+    out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
+    out.description = in.getDescription();
+
+    boolean setCommit = has(ALL_COMMITS) || (out.isCurrent && has(CURRENT_COMMIT));
+    boolean addFooters = out.isCurrent && has(COMMIT_FOOTERS);
+    if (setCommit || addFooters) {
+      checkState(rw != null);
+      checkState(repo != null);
+      Project.NameKey project = c.getProject();
+      String rev = in.getRevision().get();
+      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
+      rw.parseBody(commit);
+      if (setCommit) {
+        out.commit = getCommitInfo(project, rw, commit, has(WEB_LINKS), fillCommit);
+      }
+      if (addFooters) {
+        Ref ref = repo.exactRef(cd.change().getDest().get());
+        RevCommit mergeTip = null;
+        if (ref != null) {
+          mergeTip = rw.parseCommit(ref.getObjectId());
+          rw.parseBody(mergeTip);
+        }
+        out.commitWithFooters =
+            mergeUtilFactory
+                .create(projectCache.get(project))
+                .createCommitMessageOnSubmit(commit, mergeTip, cd.notes(), in.getId());
+      }
+    }
+
+    if (has(ALL_FILES) || (out.isCurrent && has(CURRENT_FILES))) {
+      out.files = fileInfoJson.toFileInfoMap(c, in);
+      out.files.remove(Patch.COMMIT_MSG);
+      out.files.remove(Patch.MERGE_LIST);
+    }
+
+    if (out.isCurrent && has(CURRENT_ACTIONS) && userProvider.get().isIdentifiedUser()) {
+      actionJson.addRevisionActions(
+          changeInfo,
+          out,
+          new RevisionResource(changeResourceFactory.create(cd.notes(), userProvider.get()), in));
+    }
+
+    if (gpgApi.isEnabled() && has(PUSH_CERTIFICATES)) {
+      if (in.getPushCertificate() != null) {
+        out.pushCertificate =
+            gpgApi.checkPushCertificate(
+                in.getPushCertificate(), userFactory.create(in.getUploader()));
+      } else {
+        out.pushCertificate = new PushCertificateInfo();
+      }
+    }
+
+    return out;
+  }
+
+  private boolean has(ListChangesOption option) {
+    return options.contains(option);
+  }
+
+  /**
+   * @return {@link com.google.gerrit.server.permissions.PermissionBackend.ForChange} constructed
+   *     from either an index-backed or a database-backed {@link ChangeData} depending on {@code
+   *     lazyload}.
+   */
+  private PermissionBackend.ForChange permissionBackendForChange(
+      PermissionBackend.WithUser withUser, ChangeData cd) throws OrmException {
+    return lazyLoad
+        ? withUser.change(cd)
+        : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change()));
+  }
+
+  private boolean isWorldReadable(ChangeData cd)
+      throws OrmException, PermissionBackendException, IOException {
+    try {
+      permissionBackendForChange(permissionBackend.user(anonymous), cd)
+          .check(ChangePermission.READ);
+    } catch (AuthException ae) {
+      return false;
+    }
+    ProjectState projectState = projectCache.checkedGet(cd.project());
+    if (projectState == null) {
+      logger.atSevere().log("project state for project %s is null", cd.project());
+      return false;
+    }
+    return projectState.statePermitsRead();
+  }
+
+  @Nullable
+  private Repository openRepoIfNecessary(Project.NameKey project) throws IOException {
+    if (has(ALL_COMMITS) || has(CURRENT_COMMIT) || has(COMMIT_FOOTERS)) {
+      return repoManager.openRepository(project);
+    }
+    return null;
+  }
+
+  @Nullable
+  private RevWalk newRevWalk(@Nullable Repository repo) {
+    return repo != null ? new RevWalk(repo) : null;
+  }
+
+  private static boolean containsAnyOf(
+      ImmutableSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
+    return !Sets.intersection(toFind, set).isEmpty();
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 367fdb6..b4f9cc7 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -101,8 +101,10 @@
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
+import com.google.gerrit.server.change.LabelsJson;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.ReviewerSuggestion;
+import com.google.gerrit.server.change.RevisionJson;
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.events.EventListener;
 import com.google.gerrit.server.events.EventsMetrics;
@@ -260,12 +262,14 @@
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeJson.AssistedFactory.class);
     factory(CreateChangeSender.Factory.class);
+    factory(LabelsJson.Factory.class);
     factory(MergedSender.Factory.class);
     factory(MergeUtil.Factory.class);
     factory(PatchScriptFactory.Factory.class);
     factory(ProjectState.Factory.class);
     factory(RegisterNewEmailSender.Factory.class);
     factory(ReplacePatchSetSender.Factory.class);
+    factory(RevisionJson.Factory.class);
     factory(SetAssigneeSender.Factory.class);
     factory(InboundEmailRejectionSender.Factory.class);
     bind(PermissionCollection.Factory.class);
diff --git a/java/com/google/gerrit/server/edit/ChangeEditJson.java b/java/com/google/gerrit/server/edit/ChangeEditJson.java
index bd9c3a6..bf20404 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditJson.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditJson.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.server.CommonConverters;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.DownloadCommandsJson;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -97,7 +97,8 @@
       FetchInfo fetchInfo = new FetchInfo(scheme.getUrl(projectName), refName);
       r.put(schemeName, fetchInfo);
 
-      ChangeJson.populateFetchMap(scheme, downloadCommands, projectName, refName, fetchInfo);
+      DownloadCommandsJson.populateFetchMap(
+          scheme, downloadCommands, projectName, refName, fetchInfo);
     }
 
     return r;
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 3a10fc5..79876df 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.RevisionJson;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -64,15 +65,18 @@
   private final ChangeData.Factory changeDataFactory;
   private final Provider<ReviewDb> db;
   private final ChangeJson.Factory changeJsonFactory;
+  private final RevisionJson.Factory revisionJsonFactory;
 
   @Inject
   EventUtil(
       ChangeJson.Factory changeJsonFactory,
+      RevisionJson.Factory revisionJsonFactory,
       ChangeData.Factory changeDataFactory,
       Provider<ReviewDb> db) {
     this.changeDataFactory = changeDataFactory;
     this.db = db;
     this.changeJsonFactory = changeJsonFactory;
+    this.revisionJsonFactory = revisionJsonFactory;
   }
 
   public ChangeInfo changeInfo(Change change) throws OrmException {
@@ -89,7 +93,7 @@
       throws OrmException, PatchListNotAvailableException, GpgException, IOException,
           PermissionBackendException {
     ChangeData cd = changeDataFactory.create(db.get(), project, ps.getId().getParentKey());
-    return changeJsonFactory.create(CHANGE_OPTIONS).getRevisionInfo(cd, ps);
+    return revisionJsonFactory.create(CHANGE_OPTIONS).getRevisionInfo(cd, ps);
   }
 
   public AccountInfo accountInfo(AccountState accountState) {
diff --git a/java/com/google/gerrit/server/git/CodeReviewCommit.java b/java/com/google/gerrit/server/git/CodeReviewCommit.java
index e7ccd331..c210dcd 100644
--- a/java/com/google/gerrit/server/git/CodeReviewCommit.java
+++ b/java/com/google/gerrit/server/git/CodeReviewCommit.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Change;
@@ -24,6 +25,7 @@
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import java.io.IOException;
 import java.util.Optional;
+import java.util.Set;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.AnyObjectId;
@@ -126,6 +128,9 @@
    */
   private Optional<String> statusMessage = Optional.empty();
 
+  /** List of files in this commit that contain Git conflict markers. */
+  private ImmutableSet<String> filesWithGitConflicts;
+
   public CodeReviewCommit(AnyObjectId id) {
     super(id);
   }
@@ -150,6 +155,17 @@
     this.statusMessage = Optional.ofNullable(statusMessage);
   }
 
+  public ImmutableSet<String> getFilesWithGitConflicts() {
+    return filesWithGitConflicts != null ? filesWithGitConflicts : ImmutableSet.of();
+  }
+
+  public void setFilesWithGitConflicts(@Nullable Set<String> filesWithGitConflicts) {
+    this.filesWithGitConflicts =
+        filesWithGitConflicts != null && !filesWithGitConflicts.isEmpty()
+            ? ImmutableSet.copyOf(filesWithGitConflicts)
+            : null;
+  }
+
   public PatchSet.Id getPatchsetId() {
     return patchsetId;
   }
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 016415c..d5e2a41 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -16,11 +16,15 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
@@ -29,6 +33,7 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
@@ -57,15 +62,22 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
+import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.diff.Sequence;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheBuilder;
+import org.eclipse.jgit.dircache.DirCacheEntry;
 import org.eclipse.jgit.errors.AmbiguousObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.LargeObjectException;
@@ -81,6 +93,8 @@
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.MergeFormatter;
+import org.eclipse.jgit.merge.MergeResult;
 import org.eclipse.jgit.merge.MergeStrategy;
 import org.eclipse.jgit.merge.Merger;
 import org.eclipse.jgit.merge.ResolveMerger;
@@ -92,6 +106,7 @@
 import org.eclipse.jgit.revwalk.RevFlag;
 import org.eclipse.jgit.revwalk.RevSort;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.TemporaryBuffer;
 
 /**
  * Utility methods used during the merge process.
@@ -232,29 +247,158 @@
       String commitMsg,
       CodeReviewRevWalk rw,
       int parentIndex,
-      boolean ignoreIdenticalTree)
+      boolean ignoreIdenticalTree,
+      boolean allowConflicts)
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
-          MergeIdenticalTreeException, MergeConflictException {
+          MergeIdenticalTreeException, MergeConflictException, MethodNotAllowedException {
 
-    final ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
-
+    ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
     m.setBase(originalCommit.getParent(parentIndex));
+
+    DirCache dc = DirCache.newInCore();
+    if (allowConflicts && m instanceof ResolveMerger) {
+      // The DirCache must be set on ResolveMerger before calling
+      // ResolveMerger#merge(AnyObjectId...) otherwise the entries in DirCache don't get populated.
+      ((ResolveMerger) m).setDirCache(dc);
+    }
+
+    ObjectId tree;
+    ImmutableSet<String> filesWithGitConflicts;
     if (m.merge(mergeTip, originalCommit)) {
-      ObjectId tree = m.getResultTreeId();
+      filesWithGitConflicts = null;
+      tree = m.getResultTreeId();
       if (tree.equals(mergeTip.getTree()) && !ignoreIdenticalTree) {
         throw new MergeIdenticalTreeException("identical tree");
       }
+    } else {
+      if (!allowConflicts) {
+        throw new MergeConflictException("merge conflict");
+      }
 
-      CommitBuilder mergeCommit = new CommitBuilder();
-      mergeCommit.setTreeId(tree);
-      mergeCommit.setParentId(mergeTip);
-      mergeCommit.setAuthor(originalCommit.getAuthorIdent());
-      mergeCommit.setCommitter(cherryPickCommitterIdent);
-      mergeCommit.setMessage(commitMsg);
-      matchAuthorToCommitterDate(project, mergeCommit);
-      return rw.parseCommit(inserter.insert(mergeCommit));
+      if (!useContentMerge) {
+        // If content merge is disabled we don't have a ResolveMerger and hence cannot merge with
+        // conflict markers.
+        throw new MethodNotAllowedException(
+            "Cherry-pick with allow conflicts requires that content merge is enabled.");
+      }
+
+      // For merging with conflict markers we need a ResolveMerger, double-check that we have one.
+      checkState(m instanceof ResolveMerger, "allow conflicts is not supported");
+      Map<String, MergeResult<? extends Sequence>> mergeResults =
+          ((ResolveMerger) m).getMergeResults();
+
+      filesWithGitConflicts =
+          mergeResults
+              .entrySet()
+              .stream()
+              .filter(e -> e.getValue().containsConflicts())
+              .map(Map.Entry::getKey)
+              .collect(toImmutableSet());
+
+      tree =
+          mergeWithConflicts(
+              rw, inserter, dc, "HEAD", mergeTip, "CHANGE", originalCommit, mergeResults);
     }
-    throw new MergeConflictException("merge conflict");
+
+    CommitBuilder cherryPickCommit = new CommitBuilder();
+    cherryPickCommit.setTreeId(tree);
+    cherryPickCommit.setParentId(mergeTip);
+    cherryPickCommit.setAuthor(originalCommit.getAuthorIdent());
+    cherryPickCommit.setCommitter(cherryPickCommitterIdent);
+    cherryPickCommit.setMessage(commitMsg);
+    matchAuthorToCommitterDate(project, cherryPickCommit);
+    CodeReviewCommit commit = rw.parseCommit(inserter.insert(cherryPickCommit));
+    commit.setFilesWithGitConflicts(filesWithGitConflicts);
+    return commit;
+  }
+
+  public static ObjectId mergeWithConflicts(
+      RevWalk rw,
+      ObjectInserter ins,
+      DirCache dc,
+      String oursName,
+      RevCommit ours,
+      String theirsName,
+      RevCommit theirs,
+      Map<String, MergeResult<? extends Sequence>> mergeResults)
+      throws IOException {
+    rw.parseBody(ours);
+    rw.parseBody(theirs);
+    String oursMsg = ours.getShortMessage();
+    String theirsMsg = theirs.getShortMessage();
+
+    int nameLength = Math.max(oursName.length(), theirsName.length());
+    String oursNameFormatted =
+        String.format(
+            "%0$-" + nameLength + "s (%s %s)",
+            oursName,
+            ours.abbreviate(6).name(),
+            oursMsg.substring(0, Math.min(oursMsg.length(), 60)));
+    String theirsNameFormatted =
+        String.format(
+            "%0$-" + nameLength + "s (%s %s)",
+            theirsName,
+            theirs.abbreviate(6).name(),
+            theirsMsg.substring(0, Math.min(theirsMsg.length(), 60)));
+
+    MergeFormatter fmt = new MergeFormatter();
+    Map<String, ObjectId> resolved = new HashMap<>();
+    for (Map.Entry<String, MergeResult<? extends Sequence>> entry : mergeResults.entrySet()) {
+      MergeResult<? extends Sequence> p = entry.getValue();
+      try (TemporaryBuffer buf = new TemporaryBuffer.LocalFile(null, 10 * 1024 * 1024)) {
+        fmt.formatMerge(buf, p, "BASE", oursNameFormatted, theirsNameFormatted, UTF_8.name());
+        buf.close();
+
+        try (InputStream in = buf.openInputStream()) {
+          resolved.put(entry.getKey(), ins.insert(Constants.OBJ_BLOB, buf.length(), in));
+        }
+      }
+    }
+
+    DirCacheBuilder builder = dc.builder();
+    int cnt = dc.getEntryCount();
+    for (int i = 0; i < cnt; ) {
+      DirCacheEntry entry = dc.getEntry(i);
+      if (entry.getStage() == 0) {
+        builder.add(entry);
+        i++;
+        continue;
+      }
+
+      int next = dc.nextEntry(i);
+      String path = entry.getPathString();
+      DirCacheEntry res = new DirCacheEntry(path);
+      if (resolved.containsKey(path)) {
+        // For a file with content merge conflict that we produced a result
+        // above on, collapse the file down to a single stage 0 with just
+        // the blob content, and a randomly selected mode (the lowest stage,
+        // which should be the merge base, or ours).
+        res.setFileMode(entry.getFileMode());
+        res.setObjectId(resolved.get(path));
+
+      } else if (next == i + 1) {
+        // If there is exactly one stage present, shouldn't be a conflict...
+        res.setFileMode(entry.getFileMode());
+        res.setObjectId(entry.getObjectId());
+
+      } else if (next == i + 2) {
+        // Two stages suggests a delete/modify conflict. Pick the higher
+        // stage as the automatic result.
+        entry = dc.getEntry(i + 1);
+        res.setFileMode(entry.getFileMode());
+        res.setObjectId(entry.getObjectId());
+
+      } else {
+        // 3 stage conflict, no resolve above
+        // Punt on the 3-stage conflict and show the base, for now.
+        res.setFileMode(entry.getFileMode());
+        res.setObjectId(entry.getObjectId());
+      }
+      builder.add(res);
+      i = next;
+    }
+    builder.finish();
+    return dc.writeTree(ins);
   }
 
   public static RevCommit createMergeCommit(
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index 114107d..52ebd35 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -14,11 +14,21 @@
 
 package com.google.gerrit.server.git.receive;
 
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Histogram1;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.ConfigUtil;
@@ -164,7 +174,39 @@
     }
   }
 
+  @Singleton
+  private static class Metrics {
+    private final Histogram1<ResultChangeIds.Key> changes;
+    private final Timer1<String> latencyPerChange;
+    private final Counter0 timeouts;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      changes =
+          metricMaker.newHistogram(
+              "receivecommits/changes",
+              new Description("number of changes uploaded in a single push.").setCumulative(),
+              Field.ofEnum(
+                  ResultChangeIds.Key.class,
+                  "type",
+                  "type of update (replace, create, autoclose)"));
+      latencyPerChange =
+          metricMaker.newTimer(
+              "receivecommits/latency",
+              new Description("average delay per updated change")
+                  .setUnit(Units.MILLISECONDS)
+                  .setCumulative(),
+              Field.ofString("type", "type of update (create/replace, autoclose)"));
+
+      timeouts =
+          metricMaker.newCounter(
+              "receivecommits/timeout", new Description("rate of push timeouts").setRate());
+    }
+  }
+
+  private final Metrics metrics;
   private final ReceiveCommits receiveCommits;
+  private final ResultChangeIds resultChangeIds;
   private final PermissionBackend.ForProject perm;
   private final ReceivePack receivePack;
   private final ExecutorService executor;
@@ -188,6 +230,7 @@
       TransferConfig transferConfig,
       Provider<LazyPostReceiveHookChain> lazyPostReceive,
       ContributorAgreementsChecker contributorAgreements,
+      Metrics metrics,
       @Named(TIMEOUT_NAME) long timeoutMillis,
       @Assisted ProjectState projectState,
       @Assisted IdentifiedUser user,
@@ -202,6 +245,7 @@
     this.projectState = projectState;
     this.user = user;
     this.repo = repo;
+    this.metrics = metrics;
 
     Project.NameKey projectName = projectState.getNameKey();
     receivePack = new ReceivePack(repo);
@@ -237,7 +281,10 @@
     advHooks.add(new HackPushNegotiateHook());
     receivePack.setAdvertiseRefsHook(AdvertiseRefsHookChain.newChain(advHooks));
 
-    receiveCommits = factory.create(projectState, user, receivePack, allRefsWatcher, messageSender);
+    resultChangeIds = new ResultChangeIds();
+    receiveCommits =
+        factory.create(
+            projectState, user, receivePack, allRefsWatcher, messageSender, resultChangeIds);
     receiveCommits.init();
   }
 
@@ -268,11 +315,14 @@
       // pre-receive hooks
       return;
     }
+
+    long startNanos = System.nanoTime();
     Worker w = new Worker(commands);
     try {
       w.progress.waitFor(
           executor.submit(scopePropagator.wrap(w)), timeoutMillis, TimeUnit.MILLISECONDS);
     } catch (ExecutionException e) {
+      metrics.timeouts.increment();
       logger.atWarning().withCause(e).log(
           "Error in ReceiveCommits while processing changes for project %s",
           projectState.getName());
@@ -287,6 +337,23 @@
     } finally {
       w.sendMessages();
     }
+
+    long deltaNanos = System.nanoTime() - startNanos;
+    int totalChanges = 0;
+    for (ResultChangeIds.Key key : ResultChangeIds.Key.values()) {
+      List<Change.Id> ids = resultChangeIds.get(key);
+      metrics.changes.record(key, ids.size());
+      totalChanges += ids.size();
+    }
+
+    if (totalChanges > 0) {
+      metrics.latencyPerChange.record(
+          resultChangeIds.get(ResultChangeIds.Key.AUTOCLOSED).isEmpty()
+              ? "CREATE_REPLACE"
+              : ResultChangeIds.Key.AUTOCLOSED.name(),
+          deltaNanos / totalChanges,
+          NANOSECONDS);
+    }
   }
 
   public ReceivePack getReceivePack() {
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index 0724215..f762611 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -6,6 +6,7 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/logging",
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 1a626e0..2e51c17 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -107,6 +107,7 @@
 import com.google.gerrit.server.git.ReceivePackInitializer;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.ValidationError;
+import com.google.gerrit.server.git.receive.ResultChangeIds.Key;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.RefOperationValidationException;
 import com.google.gerrit.server.git.validators.RefOperationValidators;
@@ -228,7 +229,8 @@
         IdentifiedUser user,
         ReceivePack receivePack,
         AllRefsWatcher allRefsWatcher,
-        MessageSender messageSender);
+        MessageSender messageSender,
+        ResultChangeIds resultChangeIds);
   }
 
   private class ReceivePackMessageSender implements MessageSender {
@@ -352,6 +354,7 @@
   private Optional<String> tracePushOption;
 
   private MessageSender messageSender;
+  private ResultChangeIds resultChangeIds;
 
   @Inject
   ReceiveCommits(
@@ -393,7 +396,8 @@
       @Assisted IdentifiedUser user,
       @Assisted ReceivePack rp,
       @Assisted AllRefsWatcher allRefsWatcher,
-      @Nullable @Assisted MessageSender messageSender)
+      @Nullable @Assisted MessageSender messageSender,
+      @Assisted ResultChangeIds resultChangeIds)
       throws IOException {
     // Injected fields.
     this.accountResolver = accountResolver;
@@ -460,6 +464,7 @@
 
     // Handles for outputting back over the wire to the end user.
     this.messageSender = messageSender != null ? messageSender : new ReceivePackMessageSender();
+    this.resultChangeIds = resultChangeIds;
   }
 
   void init() {
@@ -810,6 +815,13 @@
       } catch (UpdateException e) {
         throw INSERT_EXCEPTION.apply(e);
       }
+
+      replaceByChange
+          .values()
+          .stream()
+          .forEach(req -> resultChangeIds.add(Key.REPLACED, req.ontoChange));
+      newChanges.stream().forEach(req -> resultChangeIds.add(Key.CREATED, req.changeId));
+
       if (magicBranchCmd != null) {
         magicBranchCmd.setResult(OK);
       }
@@ -3031,6 +3043,7 @@
   private void autoCloseChanges(ReceiveCommand cmd, Task progress) {
     logger.atFine().log("Starting auto-closing of changes");
     String refName = cmd.getRefName();
+    Set<Change.Id> ids = new HashSet<>();
 
     // TODO(dborowitz): Combine this BatchUpdate with the main one in
     // handleRegularCommands
@@ -3107,6 +3120,7 @@
                         .create(requestScopePropagator, req.psId, refName)
                         .setPatchSetProvider(req.replaceOp::getPatchSet));
                 bu.addOp(id, new ChangeProgressOp(progress));
+                ids.add(id);
               }
 
               logger.atFine().log(
@@ -3115,7 +3129,14 @@
               bu.execute();
             } catch (IOException | OrmException | PermissionBackendException e) {
               logger.atSevere().withCause(e).log("Failed to auto-close changes");
+              return null;
             }
+
+            // If we are here, we didn't throw UpdateException. Record the result.
+            // The ordering is indeterminate due to the HashSet; unfortunately, Change.Id doesn't
+            // fit into TreeSet.
+            ids.stream().forEach(id -> resultChangeIds.add(Key.AUTOCLOSED, id));
+
             return null;
           },
           // Use a multiple of the default timeout to account for inner retries that may otherwise
diff --git a/java/com/google/gerrit/server/git/receive/ResultChangeIds.java b/java/com/google/gerrit/server/git/receive/ResultChangeIds.java
new file mode 100644
index 0000000..4099e14
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/ResultChangeIds.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.reviewdb.client.Change;
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Keeps track of the change IDs thus far updated by ReceiveCommit.
+ *
+ * <p>This class is thread-safe.
+ */
+public class ResultChangeIds {
+  enum Key {
+    CREATED,
+    REPLACED,
+    AUTOCLOSED,
+  }
+
+  private final Map<Key, List<Change.Id>> ids;
+
+  ResultChangeIds() {
+    ids = new EnumMap<>(Key.class);
+    for (Key k : Key.values()) {
+      ids.put(k, new ArrayList<>());
+    }
+  }
+
+  /** Record a change ID update as having completed. Thread-safe. */
+  public void add(Key key, Change.Id id) {
+    synchronized (this) {
+      ids.get(key).add(id);
+    }
+  }
+
+  /** Returns change IDs of the given type for which the BatchUpdate succeeded. Thread-safe. */
+  public List<Change.Id> get(Key key) {
+    synchronized (this) {
+      return ImmutableList.copyOf(ids.get(key));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/group/GroupResolver.java b/java/com/google/gerrit/server/group/GroupResolver.java
new file mode 100644
index 0000000..5fe3e8e
--- /dev/null
+++ b/java/com/google/gerrit/server/group/GroupResolver.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupBackends;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Optional;
+
+@Singleton
+public class GroupResolver {
+  private final GroupBackend groupBackend;
+  private final GroupCache groupCache;
+  private final GroupControl.Factory groupControlFactory;
+
+  @Inject
+  GroupResolver(
+      GroupBackend groupBackend, GroupCache groupCache, GroupControl.Factory groupControlFactory) {
+    this.groupBackend = groupBackend;
+    this.groupCache = groupCache;
+    this.groupControlFactory = groupControlFactory;
+  }
+
+  /**
+   * Parses a group ID from a request body and returns the group.
+   *
+   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
+   * @return the group
+   * @throws UnprocessableEntityException thrown if the group ID cannot be resolved or if the group
+   *     is not visible to the calling user
+   */
+  public GroupDescription.Basic parse(String id) throws UnprocessableEntityException {
+    GroupDescription.Basic group = parseId(id);
+    if (group == null || !groupControlFactory.controlFor(group).isVisible()) {
+      throw new UnprocessableEntityException(String.format("Group Not Found: %s", id));
+    }
+    return group;
+  }
+
+  /**
+   * Parses a group ID from a request body and returns the group if it is a Gerrit internal group.
+   *
+   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
+   * @return the group
+   * @throws UnprocessableEntityException thrown if the group ID cannot be resolved, if the group is
+   *     not visible to the calling user or if it's an external group
+   */
+  public GroupDescription.Internal parseInternal(String id) throws UnprocessableEntityException {
+    GroupDescription.Basic group = parse(id);
+    if (group instanceof GroupDescription.Internal) {
+      return (GroupDescription.Internal) group;
+    }
+
+    throw new UnprocessableEntityException(String.format("External Group Not Allowed: %s", id));
+  }
+
+  /**
+   * Parses a group ID and returns the group without making any permission check whether the current
+   * user can see the group.
+   *
+   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
+   * @return the group, null if no group is found for the given group ID
+   */
+  public GroupDescription.Basic parseId(String id) {
+    AccountGroup.UUID uuid = new AccountGroup.UUID(id);
+    if (groupBackend.handles(uuid)) {
+      GroupDescription.Basic d = groupBackend.get(uuid);
+      if (d != null) {
+        return d;
+      }
+    }
+
+    // Might be a numeric AccountGroup.Id. -> Internal group.
+    if (id.matches("^[1-9][0-9]*$")) {
+      try {
+        AccountGroup.Id groupId = AccountGroup.Id.parse(id);
+        Optional<InternalGroup> group = groupCache.get(groupId);
+        if (group.isPresent()) {
+          return new InternalGroupDescription(group.get());
+        }
+      } catch (IllegalArgumentException e) {
+        // Ignored
+      }
+    }
+
+    // Might be a group name, be nice and accept unique names.
+    GroupReference ref = GroupBackends.findExactSuggestion(groupBackend, id);
+    if (ref != null) {
+      GroupDescription.Basic d = groupBackend.get(ref.getUUID());
+      if (d != null) {
+        return d;
+      }
+    }
+
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java b/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
index ab2203e..9fa1edc 100644
--- a/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
+++ b/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
@@ -230,8 +230,9 @@
     String newNoteDbStateStr = change.getNoteDbState();
     if (newNoteDbStateStr == null) {
       throw new OrmException(
-          "Rebuilding change %s produced no writes to NoteDb: "
-              + bundleReader.fromReviewDb(db, changeId));
+          String.format(
+              "Rebuilding change %s produced no writes to NoteDb: %s",
+              changeId, bundleReader.fromReviewDb(db, changeId)));
     }
     NoteDbChangeState newNoteDbState =
         checkNotNull(NoteDbChangeState.parse(changeId, newNoteDbStateStr));
@@ -290,8 +291,7 @@
     // Can only rebuild a change if its primary storage is ReviewDb.
     NoteDbChangeState s = NoteDbChangeState.parse(c);
     if (s != null && s.getPrimaryStorage() != PrimaryStorage.REVIEW_DB) {
-      throw new OrmException(
-          String.format("cannot rebuild change " + c.getId() + " with state " + s));
+      throw new OrmException(String.format("cannot rebuild change %s with state %s", c.getId(), s));
     }
     return c;
   }
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 15fedd7..285c37d 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.patch;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -24,18 +23,12 @@
 import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.InMemoryInserter;
+import com.google.gerrit.server.git.MergeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.io.InputStream;
-import java.util.HashMap;
-import java.util.Map;
-import org.eclipse.jgit.diff.Sequence;
 import org.eclipse.jgit.dircache.DirCache;
-import org.eclipse.jgit.dircache.DirCacheBuilder;
-import org.eclipse.jgit.dircache.DirCacheEntry;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -43,14 +36,11 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.merge.MergeFormatter;
-import org.eclipse.jgit.merge.MergeResult;
 import org.eclipse.jgit.merge.ResolveMerger;
 import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.TemporaryBuffer;
 
 public class AutoMerger {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -127,84 +117,17 @@
     ObjectId treeId;
     if (couldMerge) {
       treeId = m.getResultTreeId();
-
     } else {
-      RevCommit ours = merge.getParent(0);
-      RevCommit theirs = merge.getParent(1);
-      rw.parseBody(ours);
-      rw.parseBody(theirs);
-      String oursMsg = ours.getShortMessage();
-      String theirsMsg = theirs.getShortMessage();
-
-      String oursName =
-          String.format(
-              "HEAD   (%s %s)",
-              ours.abbreviate(6).name(), oursMsg.substring(0, Math.min(oursMsg.length(), 60)));
-      String theirsName =
-          String.format(
-              "BRANCH (%s %s)",
-              theirs.abbreviate(6).name(),
-              theirsMsg.substring(0, Math.min(theirsMsg.length(), 60)));
-
-      MergeFormatter fmt = new MergeFormatter();
-      Map<String, MergeResult<? extends Sequence>> r = m.getMergeResults();
-      Map<String, ObjectId> resolved = new HashMap<>();
-      for (Map.Entry<String, MergeResult<? extends Sequence>> entry : r.entrySet()) {
-        MergeResult<? extends Sequence> p = entry.getValue();
-        try (TemporaryBuffer buf = new TemporaryBuffer.LocalFile(null, 10 * 1024 * 1024)) {
-          fmt.formatMerge(buf, p, "BASE", oursName, theirsName, UTF_8.name());
-          buf.close();
-
-          try (InputStream in = buf.openInputStream()) {
-            resolved.put(entry.getKey(), ins.insert(Constants.OBJ_BLOB, buf.length(), in));
-          }
-        }
-      }
-
-      DirCacheBuilder builder = dc.builder();
-      int cnt = dc.getEntryCount();
-      for (int i = 0; i < cnt; ) {
-        DirCacheEntry entry = dc.getEntry(i);
-        if (entry.getStage() == 0) {
-          builder.add(entry);
-          i++;
-          continue;
-        }
-
-        int next = dc.nextEntry(i);
-        String path = entry.getPathString();
-        DirCacheEntry res = new DirCacheEntry(path);
-        if (resolved.containsKey(path)) {
-          // For a file with content merge conflict that we produced a result
-          // above on, collapse the file down to a single stage 0 with just
-          // the blob content, and a randomly selected mode (the lowest stage,
-          // which should be the merge base, or ours).
-          res.setFileMode(entry.getFileMode());
-          res.setObjectId(resolved.get(path));
-
-        } else if (next == i + 1) {
-          // If there is exactly one stage present, shouldn't be a conflict...
-          res.setFileMode(entry.getFileMode());
-          res.setObjectId(entry.getObjectId());
-
-        } else if (next == i + 2) {
-          // Two stages suggests a delete/modify conflict. Pick the higher
-          // stage as the automatic result.
-          entry = dc.getEntry(i + 1);
-          res.setFileMode(entry.getFileMode());
-          res.setObjectId(entry.getObjectId());
-
-        } else {
-          // 3 stage conflict, no resolve above
-          // Punt on the 3-stage conflict and show the base, for now.
-          res.setFileMode(entry.getFileMode());
-          res.setObjectId(entry.getObjectId());
-        }
-        builder.add(res);
-        i = next;
-      }
-      builder.finish();
-      treeId = dc.writeTree(ins);
+      treeId =
+          MergeUtil.mergeWithConflicts(
+              rw,
+              ins,
+              dc,
+              "HEAD",
+              merge.getParent(0),
+              "BRANCH",
+              merge.getParent(1),
+              m.getMergeResults());
     }
 
     return commit(repo, rw, tmpIns, ins, refName, treeId, merge);
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index a0091a3..df98f5e 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -104,7 +104,7 @@
       Account.Id reviewer,
       int value)
       throws PermissionBackendException {
-    if (!change.getStatus().isOpen()) {
+    if (change.getStatus().equals(Change.Status.MERGED)) {
       return false;
     }
 
diff --git a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
index 370833c..c301ab2 100644
--- a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
+++ b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.restapi.account;
 
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -22,10 +21,6 @@
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountResolver;
@@ -39,25 +34,19 @@
 
 @Singleton
 public class AccountsCollection implements RestCollection<TopLevelResource, AccountResource> {
-  private final Provider<CurrentUser> self;
-  private final AccountResolver resolver;
+  private final AccountResolver accountResolver;
   private final AccountControl.Factory accountControlFactory;
-  private final IdentifiedUser.GenericFactory userFactory;
   private final Provider<QueryAccounts> list;
   private final DynamicMap<RestView<AccountResource>> views;
 
   @Inject
   public AccountsCollection(
-      Provider<CurrentUser> self,
-      AccountResolver resolver,
+      AccountResolver accountResolver,
       AccountControl.Factory accountControlFactory,
-      IdentifiedUser.GenericFactory userFactory,
       Provider<QueryAccounts> list,
       DynamicMap<RestView<AccountResource>> views) {
-    this.self = self;
-    this.resolver = resolver;
+    this.accountResolver = accountResolver;
     this.accountControlFactory = accountControlFactory;
-    this.userFactory = userFactory;
     this.list = list;
     this.views = views;
   }
@@ -66,7 +55,7 @@
   public AccountResource parse(TopLevelResource root, IdString id)
       throws ResourceNotFoundException, AuthException, OrmException, IOException,
           ConfigInvalidException {
-    IdentifiedUser user = parseId(id.get());
+    IdentifiedUser user = accountResolver.parseId(id.get());
     if (user == null || !accountControlFactory.get().canSee(user.getAccount())) {
       throw new ResourceNotFoundException(
           String.format("Account '%s' is not found or ambiguous", id));
@@ -74,76 +63,6 @@
     return new AccountResource(user);
   }
 
-  /**
-   * Parses a account ID from a request body and returns the user.
-   *
-   * @param id ID of the account, can be a string of the format "{@code Full Name
-   *     <email@example.com>}", just the email address, a full name if it is unique, an account ID,
-   *     a user name or "{@code self}" for the calling user
-   * @return the user, never null.
-   * @throws UnprocessableEntityException thrown if the account ID cannot be resolved or if the
-   *     account is not visible to the calling user
-   */
-  public IdentifiedUser parse(String id)
-      throws AuthException, UnprocessableEntityException, OrmException, IOException,
-          ConfigInvalidException {
-    return parseOnBehalfOf(null, id);
-  }
-
-  /**
-   * Parses an account ID and returns the user without making any permission check whether the
-   * current user can see the account.
-   *
-   * @param id ID of the account, can be a string of the format "{@code Full Name
-   *     <email@example.com>}", just the email address, a full name if it is unique, an account ID,
-   *     a user name or "{@code self}" for the calling user
-   * @return the user, null if no user is found for the given account ID
-   * @throws AuthException thrown if 'self' is used as account ID and the current user is not
-   *     authenticated
-   * @throws OrmException
-   * @throws ConfigInvalidException
-   * @throws IOException
-   */
-  public IdentifiedUser parseId(String id)
-      throws AuthException, OrmException, IOException, ConfigInvalidException {
-    return parseIdOnBehalfOf(null, id);
-  }
-
-  /**
-   * Like {@link #parse(String)}, but also sets the {@link CurrentUser#getRealUser()} on the result.
-   */
-  public IdentifiedUser parseOnBehalfOf(@Nullable CurrentUser caller, String id)
-      throws AuthException, UnprocessableEntityException, OrmException, IOException,
-          ConfigInvalidException {
-    IdentifiedUser user = parseIdOnBehalfOf(caller, id);
-    if (user == null || !accountControlFactory.get().canSee(user.getAccount())) {
-      throw new UnprocessableEntityException(
-          String.format("Account '%s' is not found or ambiguous", id));
-    }
-    return user;
-  }
-
-  private IdentifiedUser parseIdOnBehalfOf(@Nullable CurrentUser caller, String id)
-      throws AuthException, OrmException, IOException, ConfigInvalidException {
-    if (id.equals("self")) {
-      CurrentUser user = self.get();
-      if (user.isIdentifiedUser()) {
-        return user.asIdentifiedUser();
-      } else if (user instanceof AnonymousUser) {
-        throw new AuthException("Authentication required");
-      } else {
-        return null;
-      }
-    }
-
-    Account match = resolver.find(id);
-    if (match == null) {
-      return null;
-    }
-    CurrentUser realUser = caller != null ? caller.getRealUser() : null;
-    return userFactory.runAs(null, match.getId(), realUser);
-  }
-
   @Override
   public RestView<TopLevelResource> list() throws ResourceNotFoundException {
     return list.get();
diff --git a/java/com/google/gerrit/server/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
index 0e8eb70..4185f36 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -46,11 +46,11 @@
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.restapi.group.GroupsCollection;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -68,7 +68,7 @@
 public class CreateAccount
     implements RestCollectionCreateView<TopLevelResource, AccountResource, AccountInput> {
   private final Sequences seq;
-  private final GroupsCollection groupsCollection;
+  private final GroupResolver groupResolver;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
@@ -80,7 +80,7 @@
   @Inject
   CreateAccount(
       Sequences seq,
-      GroupsCollection groupsCollection,
+      GroupResolver groupResolver,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
@@ -89,7 +89,7 @@
       @UserInitiated Provider<GroupsUpdate> groupsUpdate,
       OutgoingEmailValidator validator) {
     this.seq = seq;
-    this.groupsCollection = groupsCollection;
+    this.groupResolver = groupResolver;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
     this.accountsUpdateProvider = accountsUpdateProvider;
@@ -183,7 +183,7 @@
     Set<AccountGroup.UUID> groupUuids = new HashSet<>();
     if (groups != null) {
       for (String g : groups) {
-        GroupDescription.Internal internalGroup = groupsCollection.parseInternal(g);
+        GroupDescription.Internal internalGroup = groupResolver.parseInternal(g);
         groupUuids.add(internalGroup.getGroupUUID());
       }
     }
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPick.java b/java/com/google/gerrit/server/restapi/change/CherryPick.java
index 53d81d7..b68122e 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPick.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPick.java
@@ -18,13 +18,12 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CherryPickChangeInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.RevisionResource;
@@ -50,7 +49,7 @@
 
 @Singleton
 public class CherryPick
-    extends RetryingRestModifyView<RevisionResource, CherryPickInput, ChangeInfo>
+    extends RetryingRestModifyView<RevisionResource, CherryPickInput, CherryPickChangeInfo>
     implements UiAction<RevisionResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -77,7 +76,7 @@
   }
 
   @Override
-  public ChangeInfo applyImpl(
+  public CherryPickChangeInfo applyImpl(
       BatchUpdate.Factory updateFactory, RevisionResource rsrc, CherryPickInput input)
       throws OrmException, IOException, UpdateException, RestApiException,
           PermissionBackendException, ConfigInvalidException, NoSuchProjectException {
@@ -99,14 +98,19 @@
     projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
 
     try {
-      Change.Id cherryPickedChangeId =
+      CherryPickChange.Result cherryPickResult =
           cherryPickChange.cherryPick(
               updateFactory,
               rsrc.getChange(),
               rsrc.getPatchSet(),
               input,
               new Branch.NameKey(rsrc.getProject(), refName));
-      return json.noOptions().format(rsrc.getProject(), cherryPickedChangeId);
+      CherryPickChangeInfo changeInfo =
+          json.noOptions()
+              .format(rsrc.getProject(), cherryPickResult.changeId(), CherryPickChangeInfo::new);
+      changeInfo.containsGitConflicts =
+          !cherryPickResult.filesWithGitConflicts().isEmpty() ? true : null;
+      return changeInfo;
     } catch (InvalidChangeOperationException e) {
       throw new BadRequestException(e.getMessage());
     } catch (IntegrationException | NoSuchChangeException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 0de84053..65c652d 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
@@ -84,6 +86,16 @@
 
 @Singleton
 public class CherryPickChange {
+  @AutoValue
+  abstract static class Result {
+    static Result create(Change.Id changeId, ImmutableSet<String> filesWithGitConflicts) {
+      return new AutoValue_CherryPickChange_Result(changeId, filesWithGitConflicts);
+    }
+
+    abstract Change.Id changeId();
+
+    abstract ImmutableSet<String> filesWithGitConflicts();
+  }
 
   private final Provider<ReviewDb> dbProvider;
   private final Sequences seq;
@@ -132,7 +144,7 @@
     this.notifyUtil = notifyUtil;
   }
 
-  public Change.Id cherryPick(
+  public Result cherryPick(
       BatchUpdate.Factory batchUpdateFactory,
       Change change,
       PatchSet patch,
@@ -150,7 +162,7 @@
         dest);
   }
 
-  public Change.Id cherryPick(
+  public Result cherryPick(
       BatchUpdate.Factory batchUpdateFactory,
       @Nullable Change sourceChange,
       @Nullable PatchSet.Id sourcePatchId,
@@ -205,19 +217,26 @@
         throw new NoSuchProjectException(dest.getParentKey());
       }
       try {
+        MergeUtil mergeUtil;
+        if (input.allowConflicts) {
+          // allowConflicts requires to use content merge
+          mergeUtil = mergeUtilFactory.create(projectState, true);
+        } else {
+          // use content merge only if it's configured on the project
+          mergeUtil = mergeUtilFactory.create(projectState);
+        }
         cherryPickCommit =
-            mergeUtilFactory
-                .create(projectState)
-                .createCherryPickFromCommit(
-                    oi,
-                    git.getConfig(),
-                    baseCommit,
-                    commitToCherryPick,
-                    committerIdent,
-                    commitMessage,
-                    revWalk,
-                    input.parent - 1,
-                    false);
+            mergeUtil.createCherryPickFromCommit(
+                oi,
+                git.getConfig(),
+                baseCommit,
+                commitToCherryPick,
+                committerIdent,
+                commitMessage,
+                revWalk,
+                input.parent - 1,
+                false,
+                input.allowConflicts);
 
         Change.Key changeKey;
         final List<String> idList = cherryPickCommit.getFooterLines(FooterConstants.CHANGE_ID);
@@ -241,11 +260,11 @@
         try (BatchUpdate bu =
             batchUpdateFactory.create(dbProvider.get(), project, identifiedUser, now)) {
           bu.setRepository(git, revWalk, oi);
-          Change.Id result;
+          Change.Id changeId;
           if (destChanges.size() == 1) {
             // The change key exists on the destination branch. The cherry pick
             // will be added as a new patch set.
-            result = insertPatchSet(bu, git, destChanges.get(0).notes(), cherryPickCommit, input);
+            changeId = insertPatchSet(bu, git, destChanges.get(0).notes(), cherryPickCommit, input);
           } else {
             // Change key not found on destination branch. We can create a new
             // change.
@@ -253,7 +272,7 @@
             if (sourceChange != null && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
               newTopic = sourceChange.getTopic() + "-" + newDest.getShortName();
             }
-            result =
+            changeId =
                 createNewChange(
                     bu, cherryPickCommit, dest.get(), newTopic, sourceChange, sourceCommit, input);
 
@@ -265,7 +284,7 @@
             }
           }
           bu.execute();
-          return result;
+          return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
         }
       } catch (MergeIdenticalTreeException | MergeConflictException e) {
         throw new IntegrationException("Cherry pick failed: " + e.getMessage());
@@ -346,7 +365,9 @@
     Change.Id changeId = new Change.Id(seq.nextChangeId());
     ChangeInserter ins = changeInserterFactory.create(changeId, cherryPickCommit, refName);
     Branch.NameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
-    ins.setMessage(messageForDestinationChange(ins.getPatchSetId(), sourceBranch, sourceCommit))
+    ins.setMessage(
+            messageForDestinationChange(
+                ins.getPatchSetId(), sourceBranch, sourceCommit, cherryPickCommit))
         .setTopic(topic)
         .setNotify(input.notify)
         .setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
@@ -404,15 +425,27 @@
   }
 
   private String messageForDestinationChange(
-      PatchSet.Id patchSetId, Branch.NameKey sourceBranch, ObjectId sourceCommit) {
+      PatchSet.Id patchSetId,
+      Branch.NameKey sourceBranch,
+      ObjectId sourceCommit,
+      CodeReviewCommit cherryPickCommit) {
     StringBuilder stringBuilder = new StringBuilder("Patch Set ").append(patchSetId.get());
-
     if (sourceBranch != null) {
       stringBuilder.append(": Cherry Picked from branch ").append(sourceBranch.getShortName());
     } else {
       stringBuilder.append(": Cherry Picked from commit ").append(sourceCommit.getName());
     }
+    stringBuilder.append(".");
 
-    return stringBuilder.append(".").toString();
+    if (!cherryPickCommit.getFilesWithGitConflicts().isEmpty()) {
+      stringBuilder.append("\n\nThe following files contain Git conflicts:\n");
+      cherryPickCommit
+          .getFilesWithGitConflicts()
+          .stream()
+          .sorted()
+          .forEach(filePath -> stringBuilder.append("* ").append(filePath).append("\n"));
+    }
+
+    return stringBuilder.toString();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java b/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
index 11aa256..d18b172 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
@@ -16,12 +16,11 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CherryPickChangeInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 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.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
@@ -48,7 +47,7 @@
 
 @Singleton
 public class CherryPickCommit
-    extends RetryingRestModifyView<CommitResource, CherryPickInput, ChangeInfo> {
+    extends RetryingRestModifyView<CommitResource, CherryPickInput, CherryPickChangeInfo> {
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final CherryPickChange cherryPickChange;
@@ -72,7 +71,7 @@
   }
 
   @Override
-  public ChangeInfo applyImpl(
+  public CherryPickChangeInfo applyImpl(
       BatchUpdate.Factory updateFactory, CommitResource rsrc, CherryPickInput input)
       throws OrmException, IOException, UpdateException, RestApiException,
           PermissionBackendException, ConfigInvalidException, NoSuchProjectException {
@@ -97,7 +96,7 @@
     rsrc.getProjectState().checkStatePermitsWrite();
 
     try {
-      Change.Id cherryPickedChangeId =
+      CherryPickChange.Result cherryPickResult =
           cherryPickChange.cherryPick(
               updateFactory,
               null,
@@ -106,7 +105,12 @@
               commit,
               input,
               new Branch.NameKey(rsrc.getProjectState().getNameKey(), refName));
-      return json.noOptions().format(projectName, cherryPickedChangeId);
+      CherryPickChangeInfo changeInfo =
+          json.noOptions()
+              .format(projectName, cherryPickResult.changeId(), CherryPickChangeInfo::new);
+      changeInfo.containsGitConflicts =
+          !cherryPickResult.filesWithGitConflicts().isEmpty() ? true : null;
+      return changeInfo;
     } catch (InvalidChangeOperationException e) {
       throw new BadRequestException(e.getMessage());
     } catch (IntegrationException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetCommit.java b/java/com/google/gerrit/server/restapi/change/GetCommit.java
index 645d7d1..29286cb 100644
--- a/java/com/google/gerrit/server/restapi/change/GetCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/GetCommit.java
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.RevisionJson;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
@@ -33,12 +34,12 @@
 
 public class GetCommit implements RestReadView<RevisionResource> {
   private final GitRepositoryManager repoManager;
-  private final ChangeJson.Factory json;
+  private final RevisionJson.Factory json;
 
   private boolean addLinks;
 
   @Inject
-  GetCommit(GitRepositoryManager repoManager, ChangeJson.Factory json) {
+  GetCommit(GitRepositoryManager repoManager, RevisionJson.Factory json) {
     this.repoManager = repoManager;
     this.json = json;
   }
@@ -57,7 +58,9 @@
       String rev = rsrc.getPatchSet().getRevision().get();
       RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
       rw.parseBody(commit);
-      CommitInfo info = json.noOptions().toCommit(rsrc.getProject(), rw, commit, addLinks, true);
+      CommitInfo info =
+          json.create(ImmutableSet.of())
+              .getCommitInfo(rsrc.getProject(), rw, commit, addLinks, true);
       Response<CommitInfo> r = Response.ok(info);
       if (rsrc.isCacheable()) {
         r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
diff --git a/java/com/google/gerrit/server/restapi/change/GetMergeList.java b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
index 2f3b536..8e7e693 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMergeList.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.RevisionJson;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.MergeListBuilder;
@@ -38,7 +39,7 @@
 
 public class GetMergeList implements RestReadView<RevisionResource> {
   private final GitRepositoryManager repoManager;
-  private final ChangeJson.Factory json;
+  private final RevisionJson.Factory json;
 
   @Option(name = "--parent", usage = "Uninteresting parent (1-based, default = 1)")
   private int uninterestingParent = 1;
@@ -47,7 +48,7 @@
   private boolean addLinks;
 
   @Inject
-  GetMergeList(GitRepositoryManager repoManager, ChangeJson.Factory json) {
+  GetMergeList(GitRepositoryManager repoManager, RevisionJson.Factory json) {
     this.repoManager = repoManager;
     this.json = json;
   }
@@ -80,9 +81,9 @@
 
       List<RevCommit> commits = MergeListBuilder.build(rw, commit, uninterestingParent);
       List<CommitInfo> result = new ArrayList<>(commits.size());
-      ChangeJson changeJson = json.noOptions();
+      RevisionJson changeJson = json.create(ImmutableSet.of());
       for (RevCommit c : commits) {
-        result.add(changeJson.toCommit(rsrc.getProject(), rw, c, addLinks, true));
+        result.add(changeJson.getCommitInfo(rsrc.getProject(), rw, c, addLinks, true));
       }
       return createResponse(rsrc, result);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetReviewer.java b/java/com/google/gerrit/server/restapi/change/GetReviewer.java
index b9b6b09..a11380b 100644
--- a/java/com/google/gerrit/server/restapi/change/GetReviewer.java
+++ b/java/com/google/gerrit/server/restapi/change/GetReviewer.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.ReviewerJson;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
diff --git a/java/com/google/gerrit/server/restapi/change/ListReviewers.java b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
index 46bb33f..99d8746 100644
--- a/java/com/google/gerrit/server/restapi/change/ListReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.ReviewerJson;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
index 32c4ea3..7add548 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.change.ReviewerJson;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index f8b0cc4..a45a6d8 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.EmailReviewComments;
@@ -190,7 +191,7 @@
     factory(DeleteReviewerOp.Factory.class);
     factory(EmailReviewComments.Factory.class);
     factory(PatchSetInserter.Factory.class);
-    factory(PostReviewersOp.Factory.class);
+    factory(AddReviewersOp.Factory.class);
     factory(RebaseChangeOp.Factory.class);
     factory(ReviewerResource.Factory.class);
     factory(SetAssigneeOp.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index c7028ab..e47c582 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -88,10 +88,13 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.PublishCommentUtil;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.change.AddReviewersEmail;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.ChangeResource.Factory;
 import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.change.ReviewerAdder;
+import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -111,7 +114,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.restapi.account.AccountsCollection;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -167,11 +169,11 @@
   private final PublishCommentUtil publishCommentUtil;
   private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
-  private final AccountsCollection accounts;
+  private final AccountResolver accountResolver;
   private final EmailReviewComments.Factory email;
   private final CommentAdded commentAdded;
-  private final PostReviewers postReviewers;
-  private final PostReviewersEmail postReviewersEmail;
+  private final ReviewerAdder reviewerAdder;
+  private final AddReviewersEmail addReviewersEmail;
   private final NotesMigration migration;
   private final NotifyUtil notifyUtil;
   private final Config gerritConfig;
@@ -184,7 +186,7 @@
   PostReview(
       Provider<ReviewDb> db,
       RetryHelper retryHelper,
-      Factory changeResourceFactory,
+      ChangeResource.Factory changeResourceFactory,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
@@ -192,11 +194,11 @@
       PublishCommentUtil publishCommentUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache,
-      AccountsCollection accounts,
+      AccountResolver accountResolver,
       EmailReviewComments.Factory email,
       CommentAdded commentAdded,
-      PostReviewers postReviewers,
-      PostReviewersEmail postReviewersEmail,
+      ReviewerAdder reviewerAdder,
+      AddReviewersEmail addReviewersEmail,
       NotesMigration migration,
       NotifyUtil notifyUtil,
       @GerritServerConfig Config gerritConfig,
@@ -213,11 +215,11 @@
     this.patchListCache = patchListCache;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
-    this.accounts = accounts;
+    this.accountResolver = accountResolver;
     this.email = email;
     this.commentAdded = commentAdded;
-    this.postReviewers = postReviewers;
-    this.postReviewersEmail = postReviewersEmail;
+    this.reviewerAdder = reviewerAdder;
+    this.addReviewersEmail = addReviewersEmail;
     this.migration = migration;
     this.notifyUtil = notifyUtil;
     this.gerritConfig = gerritConfig;
@@ -273,21 +275,20 @@
         notifyUtil.resolveAccounts(input.notifyDetails);
 
     Map<String, AddReviewerResult> reviewerJsonResults = null;
-    List<PostReviewers.Addition> reviewerResults = Lists.newArrayList();
+    List<ReviewerAddition> reviewerResults = Lists.newArrayList();
     boolean hasError = false;
     boolean confirm = false;
     if (input.reviewers != null) {
       reviewerJsonResults = Maps.newHashMap();
       for (AddReviewerInput reviewerInput : input.reviewers) {
-        // Prevent individual PostReviewersOps from sending one email each. Instead, we call
+        // Prevent individual AddReviewersOps from sending one email each. Instead, we call
         // batchEmailReviewers at the very end to send out a single email.
         // TODO(dborowitz): I think this still sends out separate emails if any of input.reviewers
         // specifies explicit accountsToNotify. Unclear whether that's a good thing.
         reviewerInput.notify = NotifyHandling.NONE;
 
-        PostReviewers.Addition result =
-            postReviewers.prepareApplication(
-                revision.getNotes(), revision.getUser(), reviewerInput, true);
+        ReviewerAddition result =
+            reviewerAdder.prepare(revision.getNotes(), revision.getUser(), reviewerInput, true);
         reviewerJsonResults.put(reviewerInput.reviewer, result.result);
         if (result.result.error != null) {
           hasError = true;
@@ -327,7 +328,7 @@
       // Apply reviewer changes first. Revision emails should be sent to the
       // updated set of reviewers. Also keep track of whether the user added
       // themselves as a reviewer or to the CC list.
-      for (PostReviewers.Addition reviewerResult : reviewerResults) {
+      for (ReviewerAddition reviewerResult : reviewerResults) {
         bu.addOp(revision.getChange().getId(), reviewerResult.op);
         if (!ccOrReviewer && reviewerResult.result.reviewers != null) {
           for (ReviewerInfo reviewerInfo : reviewerResult.result.reviewers) {
@@ -351,8 +352,7 @@
         // User posting this review isn't currently in the reviewer or CC list,
         // isn't being explicitly added, and isn't voting on any label.
         // Automatically CC them on this change so they receive replies.
-        PostReviewers.Addition selfAddition =
-            postReviewers.ccCurrentUser(revision.getUser(), revision);
+        ReviewerAddition selfAddition = reviewerAdder.ccCurrentUser(revision.getUser(), revision);
         bu.addOp(revision.getChange().getId(), selfAddition.op);
       }
 
@@ -389,13 +389,13 @@
       // Re-read change to take into account results of the update.
       ChangeData cd =
           changeDataFactory.create(db.get(), revision.getProject(), revision.getChange().getId());
-      for (PostReviewers.Addition reviewerResult : reviewerResults) {
+      for (ReviewerAddition reviewerResult : reviewerResults) {
         reviewerResult.gatherResults(cd);
       }
 
       boolean readyForReview =
           (output.ready != null && output.ready) || !revision.getChange().isWorkInProgress();
-      // Sending from PostReviewersOp was suppressed so we can send a single batch email here.
+      // Sending from AddReviewersOp was suppressed so we can send a single batch email here.
       batchEmailReviewers(
           revision.getUser(),
           revision.getChange(),
@@ -434,7 +434,7 @@
   private void batchEmailReviewers(
       CurrentUser user,
       Change change,
-      List<PostReviewers.Addition> reviewerAdditions,
+      List<ReviewerAddition> reviewerAdditions,
       @Nullable NotifyHandling notify,
       ListMultimap<RecipientType, Account.Id> accountsToNotify,
       boolean readyForReview) {
@@ -442,7 +442,7 @@
     List<Account.Id> cc = new ArrayList<>();
     List<Address> toByEmail = new ArrayList<>();
     List<Address> ccByEmail = new ArrayList<>();
-    for (PostReviewers.Addition addition : reviewerAdditions) {
+    for (ReviewerAddition addition : reviewerAdditions) {
       if (addition.state == ReviewerState.REVIEWER) {
         to.addAll(addition.reviewers);
         toByEmail.addAll(addition.reviewersByEmail);
@@ -451,7 +451,7 @@
         ccByEmail.addAll(addition.reviewersByEmail);
       }
     }
-    postReviewersEmail.emailReviewers(
+    addReviewersEmail.emailReviewers(
         user.asIdentifiedUser(),
         change,
         to,
@@ -505,7 +505,7 @@
           String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
     }
 
-    IdentifiedUser reviewer = accounts.parseOnBehalfOf(caller, in.onBehalfOf);
+    IdentifiedUser reviewer = accountResolver.parseOnBehalfOf(caller, in.onBehalfOf);
     try {
       permissionBackend
           .user(reviewer)
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index dd6e457..9147d44 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -14,61 +14,18 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.extensions.client.ReviewerState.CC;
-import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.ReviewerInfo;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.account.GroupMembers;
-import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.NotifyUtil;
+import com.google.gerrit.server.change.ReviewerAdder;
+import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
 import com.google.gerrit.server.change.ReviewerResource;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NotesMigration;
-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.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.restapi.account.AccountsCollection;
-import com.google.gerrit.server.restapi.group.GroupsCollection;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestCollectionModifyView;
@@ -79,72 +36,27 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.text.MessageFormat;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class PostReviewers
     extends RetryingRestCollectionModifyView<
         ChangeResource, ReviewerResource, AddReviewerInput, AddReviewerResult> {
 
-  public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
-  public static final int DEFAULT_MAX_REVIEWERS = 20;
-
-  private final AccountsCollection accounts;
-  private final PermissionBackend permissionBackend;
-
-  private final GroupsCollection groupsCollection;
-  private final GroupMembers groupMembers;
-  private final AccountLoader.Factory accountLoaderFactory;
   private final Provider<ReviewDb> dbProvider;
   private final ChangeData.Factory changeDataFactory;
-  private final Config cfg;
-  private final ReviewerJson json;
-  private final NotesMigration migration;
-  private final NotifyUtil notifyUtil;
-  private final ProjectCache projectCache;
-  private final Provider<AnonymousUser> anonymousProvider;
-  private final PostReviewersOp.Factory postReviewersOpFactory;
-  private final OutgoingEmailValidator validator;
+  private final ReviewerAdder reviewerAdder;
 
   @Inject
   PostReviewers(
-      AccountsCollection accounts,
-      PermissionBackend permissionBackend,
-      GroupsCollection groupsCollection,
-      GroupMembers groupMembers,
-      AccountLoader.Factory accountLoaderFactory,
       Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       RetryHelper retryHelper,
-      @GerritServerConfig Config cfg,
-      ReviewerJson json,
-      NotesMigration migration,
-      NotifyUtil notifyUtil,
-      ProjectCache projectCache,
-      Provider<AnonymousUser> anonymousProvider,
-      PostReviewersOp.Factory postReviewersOpFactory,
-      OutgoingEmailValidator validator) {
+      ReviewerAdder reviewerAdder) {
     super(retryHelper);
-    this.accounts = accounts;
-    this.permissionBackend = permissionBackend;
-    this.groupsCollection = groupsCollection;
-    this.groupMembers = groupMembers;
-    this.accountLoaderFactory = accountLoaderFactory;
     this.dbProvider = db;
     this.changeDataFactory = changeDataFactory;
-    this.cfg = cfg;
-    this.json = json;
-    this.migration = migration;
-    this.notifyUtil = notifyUtil;
-    this.projectCache = projectCache;
-    this.anonymousProvider = anonymousProvider;
-    this.postReviewersOpFactory = postReviewersOpFactory;
-    this.validator = validator;
+    this.reviewerAdder = reviewerAdder;
   }
 
   @Override
@@ -156,7 +68,7 @@
       throw new BadRequestException("missing reviewer field");
     }
 
-    Addition addition = prepareApplication(rsrc.getNotes(), rsrc.getUser(), input, true);
+    ReviewerAddition addition = reviewerAdder.prepare(rsrc.getNotes(), rsrc.getUser(), input, true);
     if (addition.op == null) {
       return addition.result;
     }
@@ -173,349 +85,4 @@
         changeDataFactory.create(dbProvider.get(), rsrc.getProject(), rsrc.getId()));
     return addition.result;
   }
-
-  /**
-   * Prepare application of a single {@link AddReviewerInput}.
-   *
-   * @param notes change notes.
-   * @param user user performing the reviewer addition.
-   * @param input input describing user or group to add as a reviewer.
-   * @param allowGroup whether to allow
-   * @return handle describing the addition operation. If the {@code op} field is present, this
-   *     operation may be added to a {@code BatchUpdate}. Otherwise, the {@code error} field
-   *     contains information about an error that occurred
-   * @throws OrmException
-   * @throws IOException
-   * @throws PermissionBackendException
-   * @throws ConfigInvalidException
-   */
-  public Addition prepareApplication(
-      ChangeNotes notes, CurrentUser user, AddReviewerInput input, boolean allowGroup)
-      throws OrmException, IOException, PermissionBackendException, ConfigInvalidException {
-    Branch.NameKey dest = notes.getChange().getDest();
-    String reviewer = input.reviewer;
-    ReviewerState state = input.state();
-    NotifyHandling notify = input.notify;
-    ListMultimap<RecipientType, Account.Id> accountsToNotify;
-    try {
-      accountsToNotify = notifyUtil.resolveAccounts(input.notifyDetails);
-    } catch (BadRequestException e) {
-      return fail(reviewer, e.getMessage());
-    }
-    boolean confirmed = input.confirmed();
-    boolean allowByEmail =
-        projectCache
-            .checkedGet(dest.getParentKey())
-            .is(BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL);
-
-    Addition byAccountId =
-        addByAccountId(
-            reviewer, dest, user, state, notify, accountsToNotify, allowGroup, allowByEmail);
-
-    Addition wholeGroup = null;
-    if (byAccountId == null || !byAccountId.exactMatchFound) {
-      wholeGroup =
-          addWholeGroup(
-              reviewer,
-              dest,
-              user,
-              state,
-              notify,
-              accountsToNotify,
-              confirmed,
-              allowGroup,
-              allowByEmail);
-      if (wholeGroup != null && wholeGroup.exactMatchFound) {
-        return wholeGroup;
-      }
-    }
-
-    if (byAccountId != null) {
-      return byAccountId;
-    }
-    if (wholeGroup != null) {
-      return wholeGroup;
-    }
-
-    return addByEmail(reviewer, notes, user, state, notify, accountsToNotify);
-  }
-
-  Addition ccCurrentUser(CurrentUser user, RevisionResource revision) {
-    return new Addition(
-        user.getUserName().orElse(null),
-        revision.getUser(),
-        ImmutableSet.of(user.getAccountId()),
-        null,
-        CC,
-        NotifyHandling.NONE,
-        ImmutableListMultimap.of(),
-        true);
-  }
-
-  @Nullable
-  private Addition addByAccountId(
-      String reviewer,
-      Branch.NameKey dest,
-      CurrentUser user,
-      ReviewerState state,
-      NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify,
-      boolean allowGroup,
-      boolean allowByEmail)
-      throws OrmException, PermissionBackendException, IOException, ConfigInvalidException {
-    IdentifiedUser reviewerUser;
-    boolean exactMatchFound = false;
-    try {
-      reviewerUser = accounts.parse(reviewer);
-      if (reviewer.equalsIgnoreCase(reviewerUser.getName())
-          || reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) {
-        exactMatchFound = true;
-      }
-    } catch (UnprocessableEntityException | AuthException e) {
-      // AuthException won't occur since the user is authenticated at this point.
-      if (!allowGroup && !allowByEmail) {
-        // Only return failure if we aren't going to try other interpretations.
-        return fail(
-            reviewer, MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, reviewer));
-      }
-      return null;
-    }
-
-    if (isValidReviewer(dest, reviewerUser.getAccount())) {
-      return new Addition(
-          reviewer,
-          user,
-          ImmutableSet.of(reviewerUser.getAccountId()),
-          null,
-          state,
-          notify,
-          accountsToNotify,
-          exactMatchFound);
-    }
-    if (!reviewerUser.getAccount().isActive()) {
-      if (allowByEmail && state == CC) {
-        return null;
-      }
-      return fail(reviewer, MessageFormat.format(ChangeMessages.get().reviewerInactive, reviewer));
-    }
-    return fail(
-        reviewer, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, reviewer));
-  }
-
-  @Nullable
-  private Addition addWholeGroup(
-      String reviewer,
-      Branch.NameKey dest,
-      CurrentUser user,
-      ReviewerState state,
-      NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify,
-      boolean confirmed,
-      boolean allowGroup,
-      boolean allowByEmail)
-      throws IOException, PermissionBackendException {
-    if (!allowGroup) {
-      return null;
-    }
-
-    GroupDescription.Basic group;
-    try {
-      group = groupsCollection.parseInternal(reviewer);
-    } catch (UnprocessableEntityException e) {
-      if (!allowByEmail) {
-        return fail(
-            reviewer,
-            MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, reviewer));
-      }
-      return null;
-    }
-
-    if (!isLegalReviewerGroup(group.getGroupUUID())) {
-      return fail(
-          reviewer, MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
-    }
-
-    Set<Account.Id> reviewers = new HashSet<>();
-    Set<Account> members;
-    try {
-      members = groupMembers.listAccounts(group.getGroupUUID(), dest.getParentKey());
-    } catch (NoSuchProjectException e) {
-      return fail(reviewer, e.getMessage());
-    }
-
-    // if maxAllowed is set to 0, it is allowed to add any number of
-    // reviewers
-    int maxAllowed = cfg.getInt("addreviewer", "maxAllowed", DEFAULT_MAX_REVIEWERS);
-    if (maxAllowed > 0 && members.size() > maxAllowed) {
-      return fail(
-          reviewer,
-          MessageFormat.format(ChangeMessages.get().groupHasTooManyMembers, group.getName()));
-    }
-
-    // if maxWithoutCheck is set to 0, we never ask for confirmation
-    int maxWithoutConfirmation =
-        cfg.getInt("addreviewer", "maxWithoutConfirmation", DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
-    if (!confirmed && maxWithoutConfirmation > 0 && members.size() > maxWithoutConfirmation) {
-      return fail(
-          reviewer,
-          true,
-          MessageFormat.format(
-              ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size()));
-    }
-
-    for (Account member : members) {
-      if (isValidReviewer(dest, member)) {
-        reviewers.add(member.getId());
-      }
-    }
-
-    return new Addition(reviewer, user, reviewers, null, state, notify, accountsToNotify, true);
-  }
-
-  @Nullable
-  private Addition addByEmail(
-      String reviewer,
-      ChangeNotes notes,
-      CurrentUser user,
-      ReviewerState state,
-      NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify)
-      throws PermissionBackendException {
-    try {
-      permissionBackend
-          .user(anonymousProvider.get())
-          .database(dbProvider)
-          .change(notes)
-          .check(ChangePermission.READ);
-    } catch (AuthException e) {
-      return fail(
-          reviewer, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, reviewer));
-    }
-
-    if (!migration.readChanges()) {
-      // addByEmail depends on NoteDb.
-      return fail(
-          reviewer, MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, reviewer));
-    }
-    Address adr = Address.tryParse(reviewer);
-    if (adr == null || !validator.isValid(adr.getEmail())) {
-      return fail(reviewer, MessageFormat.format(ChangeMessages.get().reviewerInvalid, reviewer));
-    }
-    return new Addition(
-        reviewer, user, null, ImmutableList.of(adr), state, notify, accountsToNotify, true);
-  }
-
-  private boolean isValidReviewer(Branch.NameKey branch, Account member)
-      throws PermissionBackendException {
-    if (!member.isActive()) {
-      return false;
-    }
-
-    try {
-      // Check ref permission instead of change permission, since change permissions take into
-      // account the private bit, whereas adding a user as a reviewer is explicitly allowing them to
-      // see private changes.
-      permissionBackend
-          .absentUser(member.getId())
-          .database(dbProvider)
-          .ref(branch)
-          .check(RefPermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
-  }
-
-  private Addition fail(String reviewer, String error) {
-    return fail(reviewer, false, error);
-  }
-
-  private Addition fail(String reviewer, boolean confirm, String error) {
-    Addition addition = new Addition(reviewer);
-    addition.result.confirm = confirm ? true : null;
-    addition.result.error = error;
-    return addition;
-  }
-
-  public class Addition {
-    public final AddReviewerResult result;
-    @Nullable public final PostReviewersOp op;
-    final Set<Account.Id> reviewers;
-    final Collection<Address> reviewersByEmail;
-    final ReviewerState state;
-    @Nullable final IdentifiedUser caller;
-    final boolean exactMatchFound;
-
-    Addition(String reviewer) {
-      result = new AddReviewerResult(reviewer);
-      op = null;
-      reviewers = ImmutableSet.of();
-      reviewersByEmail = ImmutableSet.of();
-      state = REVIEWER;
-      caller = null;
-      exactMatchFound = false;
-    }
-
-    Addition(
-        String reviewer,
-        CurrentUser caller,
-        @Nullable Set<Account.Id> reviewers,
-        @Nullable Collection<Address> reviewersByEmail,
-        ReviewerState state,
-        @Nullable NotifyHandling notify,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify,
-        boolean exactMatchFound) {
-      checkArgument(
-          reviewers != null || reviewersByEmail != null,
-          "must have either reviewers or reviewersByEmail");
-
-      result = new AddReviewerResult(reviewer);
-      this.reviewers = reviewers == null ? ImmutableSet.of() : reviewers;
-      this.reviewersByEmail = reviewersByEmail == null ? ImmutableList.of() : reviewersByEmail;
-      this.state = state;
-      this.caller = caller.asIdentifiedUser();
-      op =
-          postReviewersOpFactory.create(
-              this.reviewers, this.reviewersByEmail, state, notify, accountsToNotify);
-      this.exactMatchFound = exactMatchFound;
-    }
-
-    void gatherResults(ChangeData cd) throws OrmException, PermissionBackendException {
-      checkState(op != null, "addition did not result in an update op");
-      checkState(op.getResult() != null, "op did not return a result");
-
-      // Generate result details and fill AccountLoader. This occurs outside
-      // the Op because the accounts are in a different table.
-      PostReviewersOp.Result opResult = op.getResult();
-      if (migration.readChanges() && state == CC) {
-        result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
-        for (Account.Id accountId : opResult.addedCCs()) {
-          result.ccs.add(json.format(new ReviewerInfo(accountId.get()), accountId, cd));
-        }
-        accountLoaderFactory.create(true).fill(result.ccs);
-        for (Address a : reviewersByEmail) {
-          result.ccs.add(new AccountInfo(a.getName(), a.getEmail()));
-        }
-      } else {
-        result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
-        for (PatchSetApproval psa : opResult.addedReviewers()) {
-          // New reviewers have value 0, don't bother normalizing.
-          result.reviewers.add(
-              json.format(
-                  new ReviewerInfo(psa.getAccountId().get()),
-                  psa.getAccountId(),
-                  cd,
-                  ImmutableList.of(psa)));
-        }
-        accountLoaderFactory.create(true).fill(result.reviewers);
-        for (Address a : reviewersByEmail) {
-          result.reviewers.add(ReviewerInfo.byEmail(a.getName(), a.getEmail()));
-        }
-      }
-    }
-  }
-
-  public static boolean isLegalReviewerGroup(AccountGroup.UUID groupUUID) {
-    return !SystemGroupBackend.isSystemGroup(groupUUID);
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewersOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewersOp.java
deleted file mode 100644
index 6379393..0000000
--- a/java/com/google/gerrit/server/restapi/change/PostReviewersOp.java
+++ /dev/null
@@ -1,215 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.extensions.client.ReviewerState.CC;
-import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-import static java.util.stream.Collectors.toList;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Streams;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.extensions.events.ReviewerAdded;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.Set;
-
-public class PostReviewersOp implements BatchUpdateOp {
-  public interface Factory {
-    PostReviewersOp create(
-        Set<Account.Id> reviewers,
-        Collection<Address> reviewersByEmail,
-        ReviewerState state,
-        @Nullable NotifyHandling notify,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify);
-  }
-
-  @AutoValue
-  public abstract static class Result {
-    public abstract ImmutableList<PatchSetApproval> addedReviewers();
-
-    public abstract ImmutableList<Account.Id> addedCCs();
-
-    static Builder builder() {
-      return new AutoValue_PostReviewersOp_Result.Builder();
-    }
-
-    @AutoValue.Builder
-    abstract static class Builder {
-      abstract Builder setAddedReviewers(ImmutableList<PatchSetApproval> addedReviewers);
-
-      abstract Builder setAddedCCs(ImmutableList<Account.Id> addedCCs);
-
-      abstract Result build();
-    }
-  }
-
-  private final ApprovalsUtil approvalsUtil;
-  private final PatchSetUtil psUtil;
-  private final ReviewerAdded reviewerAdded;
-  private final AccountCache accountCache;
-  private final ProjectCache projectCache;
-  private final PostReviewersEmail postReviewersEmail;
-  private final NotesMigration migration;
-  private final Provider<IdentifiedUser> user;
-  private final Provider<ReviewDb> dbProvider;
-  private final Set<Account.Id> reviewers;
-  private final Collection<Address> reviewersByEmail;
-  private final ReviewerState state;
-  private final NotifyHandling notify;
-  private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
-
-  private List<PatchSetApproval> addedReviewers = new ArrayList<>();
-  private Collection<Account.Id> addedCCs = new ArrayList<>();
-  private Collection<Address> addedCCsByEmail = new ArrayList<>();
-  private Change change;
-  private PatchSet patchSet;
-  private Result opResult;
-
-  @Inject
-  PostReviewersOp(
-      ApprovalsUtil approvalsUtil,
-      PatchSetUtil psUtil,
-      ReviewerAdded reviewerAdded,
-      AccountCache accountCache,
-      ProjectCache projectCache,
-      PostReviewersEmail postReviewersEmail,
-      NotesMigration migration,
-      Provider<IdentifiedUser> user,
-      Provider<ReviewDb> dbProvider,
-      @Assisted Set<Account.Id> reviewers,
-      @Assisted Collection<Address> reviewersByEmail,
-      @Assisted ReviewerState state,
-      @Assisted @Nullable NotifyHandling notify,
-      @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-    checkArgument(state == REVIEWER || state == CC, "must be %s or %s: %s", REVIEWER, CC, state);
-    this.approvalsUtil = approvalsUtil;
-    this.psUtil = psUtil;
-    this.reviewerAdded = reviewerAdded;
-    this.accountCache = accountCache;
-    this.projectCache = projectCache;
-    this.postReviewersEmail = postReviewersEmail;
-    this.migration = migration;
-    this.user = user;
-    this.dbProvider = dbProvider;
-
-    this.reviewers = reviewers;
-    this.reviewersByEmail = reviewersByEmail;
-    this.state = state;
-    this.notify = notify;
-    this.accountsToNotify = accountsToNotify;
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx)
-      throws RestApiException, OrmException, IOException {
-    change = ctx.getChange();
-    if (!reviewers.isEmpty()) {
-      if (migration.readChanges() && state == CC) {
-        addedCCs =
-            approvalsUtil.addCcs(
-                ctx.getNotes(), ctx.getUpdate(change.currentPatchSetId()), reviewers);
-        if (addedCCs.isEmpty()) {
-          return false;
-        }
-      } else {
-        addedReviewers =
-            approvalsUtil.addReviewers(
-                ctx.getDb(),
-                ctx.getNotes(),
-                ctx.getUpdate(change.currentPatchSetId()),
-                projectCache.checkedGet(change.getProject()).getLabelTypes(change.getDest()),
-                change,
-                reviewers);
-        if (addedReviewers.isEmpty()) {
-          return false;
-        }
-      }
-    }
-
-    for (Address a : reviewersByEmail) {
-      ctx.getUpdate(change.currentPatchSetId())
-          .putReviewerByEmail(a, ReviewerStateInternal.fromReviewerState(state));
-    }
-
-    patchSet = psUtil.current(dbProvider.get(), ctx.getNotes());
-    return true;
-  }
-
-  @Override
-  public void postUpdate(Context ctx) throws Exception {
-    opResult =
-        Result.builder()
-            .setAddedReviewers(ImmutableList.copyOf(addedReviewers))
-            .setAddedCCs(ImmutableList.copyOf(addedCCs))
-            .build();
-    postReviewersEmail.emailReviewers(
-        user.get(),
-        change,
-        Lists.transform(addedReviewers, PatchSetApproval::getAccountId),
-        addedCCs == null ? ImmutableList.of() : addedCCs,
-        reviewersByEmail,
-        addedCCsByEmail,
-        notify,
-        accountsToNotify,
-        !change.isWorkInProgress());
-    if (!addedReviewers.isEmpty()) {
-      List<AccountState> reviewers =
-          addedReviewers
-              .stream()
-              .map(r -> accountCache.get(r.getAccountId()))
-              .flatMap(Streams::stream)
-              .collect(toList());
-      reviewerAdded.fire(change, patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
-    }
-  }
-
-  public Result getResult() {
-    checkState(opResult != null, "Batch update wasn't executed yet");
-    return opResult;
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
index 9d626fc..7ca674f 100644
--- a/java/com/google/gerrit/server/restapi/change/PutAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
@@ -28,13 +28,14 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.ReviewerAdder;
+import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
 import com.google.gerrit.server.change.SetAssigneeOp;
 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.restapi.account.AccountsCollection;
-import com.google.gerrit.server.restapi.change.PostReviewers.Addition;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryingRestModifyView;
@@ -51,27 +52,27 @@
 public class PutAssignee extends RetryingRestModifyView<ChangeResource, AssigneeInput, AccountInfo>
     implements UiAction<ChangeResource> {
 
-  private final AccountsCollection accounts;
+  private final AccountResolver accountResolver;
   private final SetAssigneeOp.Factory assigneeFactory;
   private final Provider<ReviewDb> db;
-  private final PostReviewers postReviewers;
+  private final ReviewerAdder reviewerAdder;
   private final AccountLoader.Factory accountLoaderFactory;
   private final PermissionBackend permissionBackend;
 
   @Inject
   PutAssignee(
-      AccountsCollection accounts,
+      AccountResolver accountResolver,
       SetAssigneeOp.Factory assigneeFactory,
       RetryHelper retryHelper,
       Provider<ReviewDb> db,
-      PostReviewers postReviewers,
+      ReviewerAdder reviewerAdder,
       AccountLoader.Factory accountLoaderFactory,
       PermissionBackend permissionBackend) {
     super(retryHelper);
-    this.accounts = accounts;
+    this.accountResolver = accountResolver;
     this.assigneeFactory = assigneeFactory;
     this.db = db;
-    this.postReviewers = postReviewers;
+    this.reviewerAdder = reviewerAdder;
     this.accountLoaderFactory = accountLoaderFactory;
     this.permissionBackend = permissionBackend;
   }
@@ -88,7 +89,7 @@
       throw new BadRequestException("missing assignee field");
     }
 
-    IdentifiedUser assignee = accounts.parse(input.assignee);
+    IdentifiedUser assignee = accountResolver.parse(input.assignee);
     if (!assignee.getAccount().isActive()) {
       throw new UnprocessableEntityException(input.assignee + " is not active");
     }
@@ -108,7 +109,7 @@
       SetAssigneeOp op = assigneeFactory.create(assignee);
       bu.addOp(rsrc.getId(), op);
 
-      PostReviewers.Addition reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
+      ReviewerAddition reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
       bu.addOp(rsrc.getId(), reviewersAddition.op);
 
       bu.execute();
@@ -116,14 +117,14 @@
     }
   }
 
-  private Addition addAssigneeAsCC(ChangeResource rsrc, String assignee)
+  private ReviewerAddition addAssigneeAsCC(ChangeResource rsrc, String assignee)
       throws OrmException, IOException, PermissionBackendException, ConfigInvalidException {
     AddReviewerInput reviewerInput = new AddReviewerInput();
     reviewerInput.reviewer = assignee;
     reviewerInput.state = ReviewerState.CC;
     reviewerInput.confirmed = true;
     reviewerInput.notify = NotifyHandling.NONE;
-    return postReviewers.prepareApplication(rsrc.getNotes(), rsrc.getUser(), reviewerInput, false);
+    return reviewerAdder.prepare(rsrc.getNotes(), rsrc.getUser(), reviewerInput, false);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index b53c4e6..6610ebb 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -136,10 +134,7 @@
 
     ChangeJson cjson = json.create(options);
     cjson.setPluginDefinedAttributesFactory(this.imp);
-    List<List<ChangeInfo>> res =
-        cjson
-            .lazyLoad(containsAnyOf(options, ChangeJson.REQUIRE_LAZY_LOAD))
-            .formatQueryResults(results);
+    List<List<ChangeInfo>> res = cjson.formatQueryResults(results);
 
     for (int n = 0; n < cnt; n++) {
       List<ChangeInfo> info = res.get(n);
@@ -149,9 +144,4 @@
     }
     return res;
   }
-
-  private static boolean containsAnyOf(
-      EnumSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
-    return !Sets.intersection(toFind, set).isEmpty();
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index 3becf24..f7959c8 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -47,6 +47,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembers;
+import com.google.gerrit.server.change.ReviewerAdder;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -383,7 +384,7 @@
     int maxAllowedWithoutConfirmation = suggestReviewers.getMaxAllowedWithoutConfirmation();
     logger.atFine().log("maxAllowedWithoutConfirmation: " + maxAllowedWithoutConfirmation);
 
-    if (!PostReviewers.isLegalReviewerGroup(group.getUUID())) {
+    if (!ReviewerAdder.isLegalReviewerGroup(group.getUUID())) {
       logger.atFine().log("Ignore group %s that is not legal as reviewer", group.getUUID());
       return result;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 8ceddf0..773d12d 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ProjectUtil;
+import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
@@ -55,7 +56,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.restapi.account.AccountsCollection;
 import com.google.gerrit.server.submit.ChangeSet;
 import com.google.gerrit.server.submit.MergeOp;
 import com.google.gerrit.server.submit.MergeSuperSet;
@@ -114,7 +114,7 @@
   private final ChangeNotes.Factory changeNotesFactory;
   private final Provider<MergeOp> mergeOpProvider;
   private final Provider<MergeSuperSet> mergeSuperSet;
-  private final AccountsCollection accounts;
+  private final AccountResolver accountResolver;
   private final String label;
   private final String labelWithParents;
   private final ParameterizedString titlePattern;
@@ -135,7 +135,7 @@
       ChangeNotes.Factory changeNotesFactory,
       Provider<MergeOp> mergeOpProvider,
       Provider<MergeSuperSet> mergeSuperSet,
-      AccountsCollection accounts,
+      AccountResolver accountResolver,
       @GerritServerConfig Config cfg,
       Provider<InternalChangeQuery> queryProvider,
       PatchSetUtil psUtil,
@@ -147,7 +147,7 @@
     this.changeNotesFactory = changeNotesFactory;
     this.mergeOpProvider = mergeOpProvider;
     this.mergeSuperSet = mergeSuperSet;
-    this.accounts = accounts;
+    this.accountResolver = accountResolver;
     this.label =
         MoreObjects.firstNonNull(
             Strings.emptyToNull(cfg.getString("change", null, "submitLabel")), "Submit");
@@ -472,7 +472,7 @@
     perm.check(ChangePermission.SUBMIT_AS);
 
     CurrentUser caller = rsrc.getUser();
-    IdentifiedUser submitter = accounts.parseOnBehalfOf(caller, in.onBehalfOf);
+    IdentifiedUser submitter = accountResolver.parseOnBehalfOf(caller, in.onBehalfOf);
     try {
       permissionBackend
           .user(submitter)
diff --git a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
index 4ced4c2..4ca986b 100644
--- a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
+++ b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
@@ -67,14 +67,11 @@
   private final Provider<MergeSuperSet> mergeSuperSet;
   private final Provider<WalkSorter> sorter;
 
-  private boolean lazyLoad = false;
-
   @Option(name = "-o", usage = "Output options")
   void addOption(String option) {
     for (ListChangesOption o : ListChangesOption.values()) {
       if (o.name().equalsIgnoreCase(option)) {
         jsonOpt.add(o);
-        lazyLoad |= ChangeJson.REQUIRE_LAZY_LOAD.contains(o);
         return;
       }
     }
@@ -150,7 +147,7 @@
 
       cds = sort(cds, hidden);
       SubmittedTogetherInfo info = new SubmittedTogetherInfo();
-      info.changes = json.create(jsonOpt).lazyLoad(lazyLoad).formatChangeDatas(cds);
+      info.changes = json.create(jsonOpt).formatChangeDatas(cds);
       info.nonVisibleChanges = hidden;
       return info;
     } catch (OrmException | IOException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
index b1d49f2..6e94218 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
@@ -19,6 +19,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.ReviewerAdder;
 import com.google.gerrit.server.config.ConfigKey;
 import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -98,12 +99,12 @@
       this.suggestAccounts = (av != AccountVisibility.NONE);
     }
 
-    this.maxAllowed = cfg.getInt("addreviewer", "maxAllowed", PostReviewers.DEFAULT_MAX_REVIEWERS);
+    this.maxAllowed = cfg.getInt("addreviewer", "maxAllowed", ReviewerAdder.DEFAULT_MAX_REVIEWERS);
     this.maxAllowedWithoutConfirmation =
         cfg.getInt(
             "addreviewer",
             "maxWithoutConfirmation",
-            PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
+            ReviewerAdder.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
 
     logger.atFine().log("AccountVisibility: %s", av.name());
   }
diff --git a/java/com/google/gerrit/server/restapi/group/AddMembers.java b/java/com/google/gerrit/server/restapi/group/AddMembers.java
index a897e1a..bdf1c74 100644
--- a/java/com/google/gerrit/server/restapi/group/AddMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/AddMembers.java
@@ -47,7 +47,6 @@
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.restapi.account.AccountsCollection;
 import com.google.gerrit.server.restapi.group.AddMembers.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -90,7 +89,6 @@
 
   private final AccountManager accountManager;
   private final AuthType authType;
-  private final AccountsCollection accounts;
   private final AccountResolver accountResolver;
   private final AccountCache accountCache;
   private final AccountLoader.Factory infoFactory;
@@ -100,14 +98,12 @@
   AddMembers(
       AccountManager accountManager,
       AuthConfig authConfig,
-      AccountsCollection accounts,
       AccountResolver accountResolver,
       AccountCache accountCache,
       AccountLoader.Factory infoFactory,
       @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
     this.accountManager = accountManager;
     this.authType = authConfig.getAuthType();
-    this.accounts = accounts;
     this.accountResolver = accountResolver;
     this.accountCache = accountCache;
     this.infoFactory = infoFactory;
@@ -151,7 +147,7 @@
       throws AuthException, UnprocessableEntityException, OrmException, IOException,
           ConfigInvalidException {
     try {
-      return accounts.parse(nameOrEmailOrId).getAccount();
+      return accountResolver.parse(nameOrEmailOrId).getAccount();
     } catch (UnprocessableEntityException e) {
       // might be because the account does not exist or because the account is
       // not visible
diff --git a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
index ca77ebf..9782ad3 100644
--- a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.SubgroupResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
@@ -76,16 +77,16 @@
     }
   }
 
-  private final GroupsCollection groupsCollection;
+  private final GroupResolver groupResolver;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final GroupJson json;
 
   @Inject
   public AddSubgroups(
-      GroupsCollection groupsCollection,
+      GroupResolver groupResolver,
       @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
       GroupJson json) {
-    this.groupsCollection = groupsCollection;
+    this.groupResolver = groupResolver;
     this.groupsUpdateProvider = groupsUpdateProvider;
     this.json = json;
   }
@@ -107,7 +108,7 @@
     List<GroupInfo> result = new ArrayList<>();
     Set<AccountGroup.UUID> subgroupUuids = new LinkedHashSet<>();
     for (String subgroupIdentifier : input.groups) {
-      GroupDescription.Basic subgroup = groupsCollection.parse(subgroupIdentifier);
+      GroupDescription.Basic subgroup = groupResolver.parse(subgroupIdentifier);
       subgroupUuids.add(subgroup.getGroupUUID());
       result.add(json.format(subgroup));
     }
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index 6b01cda..0572114 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupUUID;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
@@ -77,7 +78,7 @@
   private final PersonIdent serverIdent;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final GroupCache groupCache;
-  private final GroupsCollection groups;
+  private final GroupResolver groups;
   private final GroupJson json;
   private final PluginSetContext<GroupCreationValidationListener> groupCreationValidationListeners;
   private final AddMembers addMembers;
@@ -91,7 +92,7 @@
       @GerritPersonIdent PersonIdent serverIdent,
       @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
       GroupCache groupCache,
-      GroupsCollection groups,
+      GroupResolver groups,
       GroupJson json,
       PluginSetContext<GroupCreationValidationListener> groupCreationValidationListeners,
       AddMembers addMembers,
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
index bcacb65..d197cb8 100644
--- a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
@@ -26,12 +26,12 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
+import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.MemberResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
-import com.google.gerrit.server.restapi.account.AccountsCollection;
 import com.google.gerrit.server.restapi.group.AddMembers.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -44,13 +44,13 @@
 
 @Singleton
 public class DeleteMembers implements RestModifyView<GroupResource, Input> {
-  private final AccountsCollection accounts;
+  private final AccountResolver accountResolver;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
 
   @Inject
   DeleteMembers(
-      AccountsCollection accounts, @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
-    this.accounts = accounts;
+      AccountResolver accountResolver, @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
+    this.accountResolver = accountResolver;
     this.groupsUpdateProvider = groupsUpdateProvider;
   }
 
@@ -69,7 +69,7 @@
 
     Set<Account.Id> membersToRemove = new HashSet<>();
     for (String nameOrEmail : input.members) {
-      Account a = accounts.parse(nameOrEmail).getAccount();
+      Account a = accountResolver.parse(nameOrEmail).getAccount();
       membersToRemove.add(a.getId());
     }
     AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
index 934698b..c486af4 100644
--- a/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.SubgroupResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
@@ -43,14 +44,13 @@
 
 @Singleton
 public class DeleteSubgroups implements RestModifyView<GroupResource, Input> {
-  private final GroupsCollection groupsCollection;
+  private final GroupResolver groupResolver;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
 
   @Inject
   DeleteSubgroups(
-      GroupsCollection groupsCollection,
-      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
-    this.groupsCollection = groupsCollection;
+      GroupResolver groupResolver, @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
+    this.groupResolver = groupResolver;
     this.groupsUpdateProvider = groupsUpdateProvider;
   }
 
@@ -70,7 +70,7 @@
 
     Set<AccountGroup.UUID> subgroupsToRemove = new HashSet<>();
     for (String subgroupIdentifier : input.groups) {
-      GroupDescription.Basic subgroup = groupsCollection.parse(subgroupIdentifier);
+      GroupDescription.Basic subgroup = groupResolver.parse(subgroupIdentifier);
       subgroupsToRemove.add(subgroup.getGroupUUID());
     }
 
diff --git a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
index 40a11c7..52fe9d0 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -26,20 +25,13 @@
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupBackends;
-import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.util.Optional;
 
 public class GroupsCollection
     implements RestCollection<TopLevelResource, GroupResource>, NeedsParams {
@@ -47,8 +39,7 @@
   private final Provider<ListGroups> list;
   private final Provider<QueryGroups> queryGroups;
   private final GroupControl.Factory groupControlFactory;
-  private final GroupBackend groupBackend;
-  private final GroupCache groupCache;
+  private final GroupResolver groupResolver;
   private final Provider<CurrentUser> self;
 
   private boolean hasQuery2;
@@ -59,15 +50,13 @@
       Provider<ListGroups> list,
       Provider<QueryGroups> queryGroups,
       GroupControl.Factory groupControlFactory,
-      GroupBackend groupBackend,
-      GroupCache groupCache,
+      GroupResolver groupResolver,
       Provider<CurrentUser> self) {
     this.views = views;
     this.list = list;
     this.queryGroups = queryGroups;
     this.groupControlFactory = groupControlFactory;
-    this.groupBackend = groupBackend;
-    this.groupCache = groupCache;
+    this.groupResolver = groupResolver;
     this.self = self;
   }
 
@@ -107,7 +96,7 @@
       throw new ResourceNotFoundException(id);
     }
 
-    GroupDescription.Basic group = parseId(id.get());
+    GroupDescription.Basic group = groupResolver.parseId(id.get());
     if (group == null) {
       throw new ResourceNotFoundException(id.get());
     }
@@ -118,80 +107,6 @@
     return new GroupResource(ctl);
   }
 
-  /**
-   * Parses a group ID from a request body and returns the group.
-   *
-   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
-   * @return the group
-   * @throws UnprocessableEntityException thrown if the group ID cannot be resolved or if the group
-   *     is not visible to the calling user
-   */
-  public GroupDescription.Basic parse(String id) throws UnprocessableEntityException {
-    GroupDescription.Basic group = parseId(id);
-    if (group == null || !groupControlFactory.controlFor(group).isVisible()) {
-      throw new UnprocessableEntityException(String.format("Group Not Found: %s", id));
-    }
-    return group;
-  }
-
-  /**
-   * Parses a group ID from a request body and returns the group if it is a Gerrit internal group.
-   *
-   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
-   * @return the group
-   * @throws UnprocessableEntityException thrown if the group ID cannot be resolved, if the group is
-   *     not visible to the calling user or if it's an external group
-   */
-  public GroupDescription.Internal parseInternal(String id) throws UnprocessableEntityException {
-    GroupDescription.Basic group = parse(id);
-    if (group instanceof GroupDescription.Internal) {
-      return (GroupDescription.Internal) group;
-    }
-
-    throw new UnprocessableEntityException(String.format("External Group Not Allowed: %s", id));
-  }
-
-  /**
-   * Parses a group ID and returns the group without making any permission check whether the current
-   * user can see the group.
-   *
-   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
-   * @return the group, null if no group is found for the given group ID
-   */
-  public GroupDescription.Basic parseId(String id) {
-    AccountGroup.UUID uuid = new AccountGroup.UUID(id);
-    if (groupBackend.handles(uuid)) {
-      GroupDescription.Basic d = groupBackend.get(uuid);
-      if (d != null) {
-        return d;
-      }
-    }
-
-    // Might be a numeric AccountGroup.Id. -> Internal group.
-    if (id.matches("^[1-9][0-9]*$")) {
-      try {
-        AccountGroup.Id groupId = AccountGroup.Id.parse(id);
-        Optional<InternalGroup> group = groupCache.get(groupId);
-        if (group.isPresent()) {
-          return new InternalGroupDescription(group.get());
-        }
-      } catch (IllegalArgumentException e) {
-        // Ignored
-      }
-    }
-
-    // Might be a group name, be nice and accept unique names.
-    GroupReference ref = GroupBackends.findExactSuggestion(groupBackend, id);
-    if (ref != null) {
-      GroupDescription.Basic d = groupBackend.get(ref.getUUID());
-      if (d != null) {
-        return d;
-      }
-    }
-
-    return null;
-  }
-
   @Override
   public DynamicMap<RestView<GroupResource>> views() {
     return views;
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index bae5eff..968a7dd 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -82,7 +83,7 @@
   private final GroupJson json;
   private final GroupBackend groupBackend;
   private final Groups groups;
-  private final GroupsCollection groupsCollection;
+  private final GroupResolver groupResolver;
 
   private EnumSet<ListGroupsOption> options = EnumSet.noneOf(ListGroupsOption.class);
   private boolean visibleToAll;
@@ -217,7 +218,7 @@
       final Provider<IdentifiedUser> identifiedUser,
       final IdentifiedUser.GenericFactory userFactory,
       final GetGroups accountGetGroups,
-      final GroupsCollection groupsCollection,
+      final GroupResolver groupResolver,
       GroupJson json,
       GroupBackend groupBackend,
       Groups groups) {
@@ -230,7 +231,7 @@
     this.json = json;
     this.groupBackend = groupBackend;
     this.groups = groups;
-    this.groupsCollection = groupsCollection;
+    this.groupResolver = groupResolver;
   }
 
   public void setOptions(EnumSet<ListGroupsOption> options) {
@@ -403,7 +404,7 @@
   private List<GroupInfo> getGroupsOwnedBy(String id)
       throws OrmException, RestApiException, IOException, ConfigInvalidException,
           PermissionBackendException {
-    String uuid = groupsCollection.parse(id).getGroupUUID().get();
+    String uuid = groupResolver.parse(id).getGroupUUID().get();
     return filterGroupsOwnedBy(group -> group.getOwnerGroupUUID().get().equals(uuid));
   }
 
diff --git a/java/com/google/gerrit/server/restapi/group/PutOwner.java b/java/com/google/gerrit/server/restapi/group/PutOwner.java
index 56f856d..6ebec05 100644
--- a/java/com/google/gerrit/server/restapi/group/PutOwner.java
+++ b/java/com/google/gerrit/server/restapi/group/PutOwner.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.UserInitiated;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
@@ -39,16 +40,16 @@
 
 @Singleton
 public class PutOwner implements RestModifyView<GroupResource, OwnerInput> {
-  private final GroupsCollection groupsCollection;
+  private final GroupResolver groupResolver;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final GroupJson json;
 
   @Inject
   PutOwner(
-      GroupsCollection groupsCollection,
+      GroupResolver groupResolver,
       @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
       GroupJson json) {
-    this.groupsCollection = groupsCollection;
+    this.groupResolver = groupResolver;
     this.groupsUpdateProvider = groupsUpdateProvider;
     this.json = json;
   }
@@ -68,7 +69,7 @@
       throw new BadRequestException("owner is required");
     }
 
-    GroupDescription.Basic owner = groupsCollection.parse(input.owner);
+    GroupDescription.Basic owner = groupResolver.parse(input.owner);
     if (!internalGroup.getOwnerGroupUUID().equals(owner.getGroupUUID())) {
       AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
       InternalGroupUpdate groupUpdate =
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index 79ff3c9..09f8ab7 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -57,6 +57,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.RepositoryCaseMismatchException;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
@@ -67,7 +68,6 @@
 import com.google.gerrit.server.project.ProjectNameLockManager;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.restapi.group.GroupsCollection;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -97,7 +97,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final Provider<ProjectsCollection> projectsCollection;
-  private final Provider<GroupsCollection> groupsCollection;
+  private final Provider<GroupResolver> groupResolver;
   private final PluginSetContext<ProjectCreationValidationListener>
       projectCreationValidationListeners;
   private final ProjectJson json;
@@ -119,7 +119,7 @@
   @Inject
   CreateProject(
       Provider<ProjectsCollection> projectsCollection,
-      Provider<GroupsCollection> groupsCollection,
+      Provider<GroupResolver> groupResolver,
       ProjectJson json,
       PluginSetContext<ProjectCreationValidationListener> projectCreationValidationListeners,
       GitRepositoryManager repoManager,
@@ -137,7 +137,7 @@
       AllUsersName allUsers,
       PluginItemContext<ProjectNameLockManager> lockManager) {
     this.projectsCollection = projectsCollection;
-    this.groupsCollection = groupsCollection;
+    this.groupResolver = groupResolver;
     this.projectCreationValidationListeners = projectCreationValidationListeners;
     this.json = json;
     this.repoManager = repoManager;
@@ -187,7 +187,7 @@
     } else {
       args.ownerIds = Lists.newArrayListWithCapacity(input.owners.size());
       for (String owner : input.owners) {
-        args.ownerIds.add(groupsCollection.get().parse(owner).getGroupUUID());
+        args.ownerIds.add(groupResolver.get().parse(owner).getGroupUUID());
       }
     }
     args.contributorAgreements =
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index 8c1b0b3..1fab368 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.ioutil.RegexListSearcher;
 import com.google.gerrit.server.ioutil.StringUtil;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -50,7 +51,6 @@
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.restapi.group.GroupsCollection;
 import com.google.gerrit.server.util.TreeFormatter;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
@@ -141,7 +141,7 @@
 
   private final CurrentUser currentUser;
   private final ProjectCache projectCache;
-  private final GroupsCollection groupsCollection;
+  private final GroupResolver groupResolver;
   private final GroupControl.Factory groupControlFactory;
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
@@ -262,7 +262,7 @@
   protected ListProjects(
       CurrentUser currentUser,
       ProjectCache projectCache,
-      GroupsCollection groupsCollection,
+      GroupResolver groupResolver,
       GroupControl.Factory groupControlFactory,
       GitRepositoryManager repoManager,
       PermissionBackend permissionBackend,
@@ -270,7 +270,7 @@
       WebLinks webLinks) {
     this.currentUser = currentUser;
     this.projectCache = projectCache;
-    this.groupsCollection = groupsCollection;
+    this.groupResolver = groupResolver;
     this.groupControlFactory = groupControlFactory;
     this.repoManager = repoManager;
     this.permissionBackend = permissionBackend;
@@ -367,7 +367,7 @@
 
         if (groupUuid != null
             && !e.getLocalGroups()
-                .contains(GroupReference.forGroup(groupsCollection.parseId(groupUuid.get())))) {
+                .contains(GroupReference.forGroup(groupResolver.parseId(groupUuid.get())))) {
           continue;
         }
 
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index c5c8cf0..c8857a2 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -32,11 +32,11 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.gerrit.server.restapi.config.ListCapabilities;
-import com.google.gerrit.server.restapi.group.GroupsCollection;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -48,18 +48,18 @@
 
 @Singleton
 public class SetAccessUtil {
-  private final GroupsCollection groupsCollection;
+  private final GroupResolver groupResolver;
   private final AllProjectsName allProjects;
   private final Provider<SetParent> setParent;
   private final ListCapabilities listCapabilities;
 
   @Inject
   private SetAccessUtil(
-      GroupsCollection groupsCollection,
+      GroupResolver groupResolver,
       AllProjectsName allProjects,
       Provider<SetParent> setParent,
       ListCapabilities listCapabilities) {
-    this.groupsCollection = groupsCollection;
+    this.groupResolver = groupResolver;
     this.allProjects = allProjects;
     this.setParent = setParent;
     this.listCapabilities = listCapabilities;
@@ -91,7 +91,7 @@
 
         for (Map.Entry<String, PermissionRuleInfo> permissionRuleInfoEntry :
             permissionEntry.getValue().rules.entrySet()) {
-          GroupDescription.Basic group = groupsCollection.parseId(permissionRuleInfoEntry.getKey());
+          GroupDescription.Basic group = groupResolver.parseId(permissionRuleInfoEntry.getKey());
           if (group == null) {
             throw new UnprocessableEntityException(
                 permissionRuleInfoEntry.getKey() + " is not a valid group ID");
diff --git a/java/com/google/gerrit/server/submit/CherryPick.java b/java/com/google/gerrit/server/submit/CherryPick.java
index e0b10f0..07ac532 100644
--- a/java/com/google/gerrit/server/submit/CherryPick.java
+++ b/java/com/google/gerrit/server/submit/CherryPick.java
@@ -20,6 +20,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
@@ -90,7 +91,7 @@
 
     @Override
     protected void updateRepoImpl(RepoContext ctx)
-        throws IntegrationException, IOException, OrmException {
+        throws IntegrationException, IOException, OrmException, MethodNotAllowedException {
       // If there is only one parent, a cherry-pick can be done by taking the
       // delta relative to that one parent and redoing that on the current merge
       // tip.
@@ -116,6 +117,7 @@
                 cherryPickCmtMsg,
                 args.rw,
                 0,
+                false,
                 false);
       } catch (MergeConflictException mce) {
         // Keep going in the case of a single merge failure; the goal is to
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index fa0fdde..cf3a44e 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -156,7 +156,8 @@
                   cherryPickCmtMsg,
                   args.rw,
                   0,
-                  true);
+                  true,
+                  false);
         } catch (MergeConflictException mce) {
           // Unlike in Cherry-pick case, this should never happen.
           toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
diff --git a/java/com/google/gerrit/sshd/AbstractGitCommand.java b/java/com/google/gerrit/sshd/AbstractGitCommand.java
index 710b3dc..30b6408 100644
--- a/java/com/google/gerrit/sshd/AbstractGitCommand.java
+++ b/java/com/google/gerrit/sshd/AbstractGitCommand.java
@@ -32,8 +32,6 @@
   @Argument(index = 0, metaVar = "PROJECT.git", required = true, usage = "project name")
   protected ProjectState projectState;
 
-  @Inject private SshScope sshScope;
-
   @Inject private GitRepositoryManager repoManager;
 
   @Inject private SshSession session;
@@ -49,28 +47,24 @@
   @Override
   public void start(Environment env) {
     Context ctx = context.subContext(newSession(), context.getCommandLine());
-    final Context old = sshScope.set(ctx);
-    try {
-      startThread(
-          new ProjectCommandRunnable() {
-            @Override
-            public void executeParseCommand() throws Exception {
-              parseCommandLine();
-            }
+    startThreadWithContext(
+        ctx,
+        new ProjectCommandRunnable() {
+          @Override
+          public void executeParseCommand() throws Exception {
+            parseCommandLine();
+          }
 
-            @Override
-            public void run() throws Exception {
-              AbstractGitCommand.this.service();
-            }
+          @Override
+          public void run() throws Exception {
+            AbstractGitCommand.this.service();
+          }
 
-            @Override
-            public Project.NameKey getProjectName() {
-              return projectState.getNameKey();
-            }
-          });
-    } finally {
-      sshScope.set(old);
-    }
+          @Override
+          public Project.NameKey getProjectName() {
+            return projectState.getNameKey();
+          }
+        });
   }
 
   private SshSession newSession() {
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index 1d19a8a..3d5a440 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -50,6 +50,7 @@
 import java.io.PrintWriter;
 import java.io.StringWriter;
 import java.nio.charset.Charset;
+import java.util.Optional;
 import java.util.concurrent.Future;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.atomic.AtomicReference;
@@ -261,6 +262,38 @@
   }
 
   /**
+   * Spawn a function into its own thread with the provided context.
+   *
+   * <p>Typically this should be invoked within {@link Command#start(Environment)}, such as:
+   *
+   * <pre>
+   * startThreadWithContext(SshScope.Context context, new CommandRunnable() {
+   *   public void run() throws Exception {
+   *     runImp();
+   *   }
+   * });
+   * </pre>
+   *
+   * <p>If the function throws an exception, it is translated to a simple message for the client, a
+   * non-zero exit code, and the stack trace is logged.
+   *
+   * @param thunk the runnable to execute on the thread, performing the command's logic.
+   */
+  protected void startThreadWithContext(SshScope.Context context, CommandRunnable thunk) {
+    final TaskThunk tt = new TaskThunk(thunk, Optional.ofNullable(context));
+
+    if (isAdminHighPriorityCommand()) {
+      // Admin commands should not block the main work threads (there
+      // might be an interactive shell there), nor should they wait
+      // for the main work threads.
+      //
+      new Thread(tt, tt.toString()).start();
+    } else {
+      task.set(executor.submit(tt));
+    }
+  }
+
+  /**
    * Spawn a function into its own thread.
    *
    * <p>Typically this should be invoked within {@link Command#start(Environment)}, such as:
@@ -278,18 +311,8 @@
    *
    * @param thunk the runnable to execute on the thread, performing the command's logic.
    */
-  protected void startThread(CommandRunnable thunk) {
-    final TaskThunk tt = new TaskThunk(thunk);
-
-    if (isAdminHighPriorityCommand()) {
-      // Admin commands should not block the main work threads (there
-      // might be an interactive shell there), nor should they wait
-      // for the main work threads.
-      //
-      new Thread(tt, tt.toString()).start();
-    } else {
-      task.set(executor.submit(tt));
-    }
+  protected void startThread(final CommandRunnable thunk) {
+    startThreadWithContext(null, thunk);
   }
 
   private boolean isAdminHighPriorityCommand() {
@@ -416,18 +439,21 @@
 
   private final class TaskThunk implements CancelableRunnable, ProjectRunnable {
     private final CommandRunnable thunk;
+    private final Context taskContext;
     private final String taskName;
+
     private Project.NameKey projectName;
 
-    private TaskThunk(CommandRunnable thunk) {
+    private TaskThunk(CommandRunnable thunk, Optional<Context> oneOffContext) {
       this.thunk = thunk;
       this.taskName = getTaskName();
+      this.taskContext = oneOffContext.orElse(context);
     }
 
     @Override
     public void cancel() {
       synchronized (this) {
-        final Context old = sshScope.set(context);
+        final Context old = sshScope.set(taskContext);
         try {
           onExit(STATUS_CANCEL);
         } finally {
@@ -442,7 +468,7 @@
         final Thread thisThread = Thread.currentThread();
         final String thisName = thisThread.getName();
         int rc = 0;
-        final Context old = sshScope.set(context);
+        final Context old = sshScope.set(taskContext);
         try {
           context.started = TimeUtil.nowMs();
           thisThread.setName("SSH " + taskName);
diff --git a/java/com/google/gerrit/sshd/commands/SetProjectCommand.java b/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
index 1e177a1..8c9fc9f 100644
--- a/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
@@ -123,7 +123,7 @@
       name = "--project-state",
       aliases = {"--ps"},
       usage = "project's visibility state")
-  private ProjectState state;
+  private com.google.gerrit.extensions.client.ProjectState state;
 
   @Option(name = "--max-object-size-limit", usage = "max Git object size for this project")
   private String maxObjectSizeLimit;
@@ -138,7 +138,7 @@
     configInput.useContentMerge = contentMerge;
     configInput.useContributorAgreements = contributorAgreements;
     configInput.useSignedOffBy = signedOffBy;
-    configInput.state = state.getProject().getState();
+    configInput.state = state;
     configInput.maxObjectSizeLimit = maxObjectSizeLimit;
     // Description is different to other parameters, null won't result in
     // keeping the existing description, it would delete it.
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 63d3248..57f1be1 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -2190,6 +2190,58 @@
   }
 
   @Test
+  public void removeReviewerSelfFromMergedChangeNotPermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    setApiUser(user);
+    recommend(changeId);
+
+    setApiUser(admin);
+    approve(changeId);
+    gApi.changes().id(changeId).revision(r.getCommit().name()).submit();
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("remove reviewer not permitted");
+    gApi.changes().id(r.getChangeId()).reviewer("self").remove();
+  }
+
+  @Test
+  public void removeReviewerSelfFromAbandonedChangePermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    setApiUser(user);
+    recommend(changeId);
+
+    setApiUser(admin);
+    gApi.changes().id(changeId).abandon();
+
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).reviewer("self").remove();
+    eventRecorder.assertReviewerDeletedEvents(changeId, user.email);
+  }
+
+  @Test
+  public void removeOtherReviewerFromAbandonedChangeNotPermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    setApiUser(user);
+    recommend(changeId);
+
+    setApiUser(admin);
+    approve(changeId);
+    gApi.changes().id(changeId).abandon();
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("remove reviewer not permitted");
+    gApi.changes().id(r.getChangeId()).reviewer(admin.getId().toString()).remove();
+  }
+
+  @Test
   public void deleteVote() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index edbfe4f..505c165 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -59,6 +59,7 @@
 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.CherryPickChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.FileInfo;
@@ -317,7 +318,9 @@
     ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
 
     assertThat(orig.get().messages).hasSize(1);
-    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+    CherryPickChangeInfo changeInfo = orig.revision(r.getCommit().name()).cherryPickAsInfo(in);
+    assertThat(changeInfo.containsGitConflicts).isNull();
+    ChangeApi cherry = gApi.changes().id(changeInfo._number);
 
     Collection<ChangeMessageInfo> messages =
         gApi.changes().id(project.get() + "~master~" + r.getChangeId()).get().messages;
@@ -368,7 +371,7 @@
   }
 
   @Test
-  public void cherryPickwithNoTopic() throws Exception {
+  public void cherryPickWithNoTopic() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master");
     CherryPickInput in = new CherryPickInput();
     in.destination = "foo";
@@ -495,6 +498,116 @@
   }
 
   @Test
+  public void cherryPickConflictWithAllowConflicts() throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+    // Create a branch and push a commit to it (by-passing review)
+    String destBranch = "foo";
+    gApi.projects().name(project.get()).branch(destBranch).create(new BranchInput());
+    String destContent = "some content";
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            ImmutableMap.of(PushOneCommit.FILE_NAME, destContent, "foo.txt", "foo"));
+    push.to("refs/heads/" + destBranch);
+
+    // Create a change on master with a commit that conflicts with the commit on the other branch.
+    testRepo.reset(initial);
+    String changeContent = "another content";
+    push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            ImmutableMap.of(PushOneCommit.FILE_NAME, changeContent, "bar.txt", "bar"));
+    PushOneCommit.Result r = push.to("refs/for/master%topic=someTopic");
+
+    // Verify before the cherry-pick that the change has exactly 1 message.
+    ChangeApi changeApi = gApi.changes().id(r.getChange().getId().get());
+    assertThat(changeApi.get().messages).hasSize(1);
+
+    // Cherry-pick the change to the other branch, that should fail with a conflict.
+    CherryPickInput in = new CherryPickInput();
+    in.destination = destBranch;
+    in.message = "Cherry-Pick";
+    try {
+      changeApi.revision(r.getCommit().name()).cherryPickAsInfo(in);
+      fail("expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e.getMessage()).isEqualTo("Cherry pick failed: merge conflict");
+    }
+
+    // Cherry-pick with auto merge should succeed.
+    in.allowConflicts = true;
+    CherryPickChangeInfo cherryPickChange =
+        changeApi.revision(r.getCommit().name()).cherryPickAsInfo(in);
+    assertThat(cherryPickChange.containsGitConflicts).isTrue();
+
+    // Verify that subject and topic on the cherry-pick change have been correctly populated.
+    assertThat(cherryPickChange.subject).contains(in.message);
+    assertThat(cherryPickChange.topic).isEqualTo("someTopic-" + destBranch);
+
+    // Verify that the file content in the cherry-pick change is correct.
+    // We expect that it has conflict markers to indicate the conflict.
+    BinaryResult bin =
+        gApi.changes()
+            .id(cherryPickChange._number)
+            .current()
+            .file(PushOneCommit.FILE_NAME)
+            .content();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String fileContent = new String(os.toByteArray(), UTF_8);
+    String destSha1 = getRemoteHead(project, destBranch).abbreviate(6).name();
+    String changeSha1 = r.getCommit().abbreviate(6).name();
+    assertThat(fileContent)
+        .isEqualTo(
+            "<<<<<<< HEAD   ("
+                + destSha1
+                + " test commit)\n"
+                + destContent
+                + "\n"
+                + "=======\n"
+                + changeContent
+                + "\n"
+                + ">>>>>>> CHANGE ("
+                + changeSha1
+                + " test commit)\n");
+
+    // Get details of cherry-pick change.
+    ChangeInfo cherryPickChangeWithDetails = gApi.changes().id(cherryPickChange._number).get();
+
+    // Verify that a message has been posted on the original change.
+    String cherryPickedRevision = cherryPickChangeWithDetails.currentRevision;
+    changeApi = gApi.changes().id(r.getChange().getId().get());
+    Collection<ChangeMessageInfo> messages = changeApi.get().messages;
+    assertThat(messages).hasSize(2);
+    Iterator<ChangeMessageInfo> origIt = messages.iterator();
+    origIt.next();
+    assertThat(origIt.next().message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Cherry Picked\n\n"
+                    + "This patchset was cherry picked to branch %s as commit %s",
+                in.destination, cherryPickedRevision));
+
+    // Verify that a message has been posted on the cherry-pick change.
+    assertThat(cherryPickChangeWithDetails.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> cherryIt = cherryPickChangeWithDetails.messages.iterator();
+    assertThat(cherryIt.next().message)
+        .isEqualTo(
+            "Patch Set 1: Cherry Picked from branch master.\n\n"
+                + "The following files contain Git conflicts:\n"
+                + "* "
+                + PushOneCommit.FILE_NAME
+                + "\n");
+  }
+
+  @Test
   public void cherryPickToExistingChange() throws Exception {
     PushOneCommit.Result r1 =
         pushFactory
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 8891dee..488eb9f 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.git;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
@@ -64,6 +65,7 @@
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -945,6 +947,55 @@
     assertThat(cr.all.get(0).value).isEqualTo(2);
   }
 
+  @Test
+  public void pushForMasterWithForgedAuthorAndCommitter() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    // Create a commit with different forged author and committer.
+    RevCommit c =
+        commitBuilder()
+            .author(user.getIdent())
+            .committer(user2.getIdent())
+            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+            .message(PushOneCommit.SUBJECT)
+            .create();
+    // Push commit as "Admnistrator".
+    pushHead(testRepo, "refs/for/master");
+
+    String changeId = GitUtil.getChangeId(testRepo, c).get();
+    assertThat(getOwnerEmail(changeId)).isEqualTo(admin.email);
+    assertThat(getReviewerEmails(changeId, ReviewerState.REVIEWER))
+        .containsExactly(user.email, user2.email);
+
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).rcpt())
+        .containsExactly(user.emailAddress, user2.emailAddress);
+  }
+
+  @Test
+  public void pushNewPatchSetForMasterWithForgedAuthorAndCommitter() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    // First patch set has author and committer matching change owner.
+    PushOneCommit.Result r = pushTo("refs/for/master");
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(admin.email);
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.REVIEWER)).isEmpty();
+
+    amendBuilder()
+        .author(user.getIdent())
+        .committer(user2.getIdent())
+        .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT + "2")
+        .create();
+    pushHead(testRepo, "refs/for/master");
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(admin.email);
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.REVIEWER))
+        .containsExactly(user.email, user2.email);
+
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).rcpt())
+        .containsExactly(user.emailAddress, user2.emailAddress);
+  }
+
   /**
    * There was a bug that allowed a user with Forge Committer Identity access right to upload a
    * commit and put *votes on behalf of another user* on it. This test checks that this is not
@@ -2336,4 +2387,17 @@
   private PushOneCommit.Result amendChange(String changeId, String ref) throws Exception {
     return amendChange(changeId, ref, admin, testRepo);
   }
+
+  private String getOwnerEmail(String changeId) throws Exception {
+    return get(changeId, DETAILED_ACCOUNTS).owner.email;
+  }
+
+  private ImmutableList<String> getReviewerEmails(String changeId, ReviewerState state)
+      throws Exception {
+    Collection<AccountInfo> infos =
+        get(changeId, DETAILED_LABELS, DETAILED_ACCOUNTS).reviewers.get(state);
+    return infos != null
+        ? infos.stream().map(a -> a.email).collect(toImmutableList())
+        : ImmutableList.of();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/GitmodulesIT.java b/javatests/com/google/gerrit/acceptance/git/GitmodulesIT.java
new file mode 100644
index 0000000..a13c8c8
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/GitmodulesIT.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.git;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import org.eclipse.jgit.api.errors.TransportException;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.transport.RefSpec;
+import org.junit.Test;
+
+public class GitmodulesIT extends AbstractDaemonTest {
+  @Test
+  public void invalidSubmoduleURLIsRejected() throws Exception {
+    pushGitmodules("name", "-invalid-url", "path", "Invalid submodule URL");
+  }
+
+  @Test
+  public void invalidSubmodulePathIsRejected() throws Exception {
+    pushGitmodules("name", "http://somewhere", "-invalid-path", "Invalid submodule path");
+  }
+
+  @Test
+  public void invalidSubmoduleNameIsRejected() throws Exception {
+    pushGitmodules("-invalid-name", "http://somewhere", "path", "Invalid submodule name");
+  }
+
+  private void pushGitmodules(String name, String url, String path, String expectedErrorMessage)
+      throws Exception {
+    Config config = new Config();
+    config.setString("submodule", name, "url", url);
+    config.setString("submodule", name, "path", path);
+    TestRepository<?> repo = cloneProject(project);
+    repo.branch("HEAD")
+        .commit()
+        .insertChangeId()
+        .message("subject: adding new subscription")
+        .add(".gitmodules", config.toText().toString())
+        .create();
+
+    exception.expectMessage(expectedErrorMessage);
+    exception.expect(TransportException.class);
+    repo.git().push().setRemote("origin").setRefSpecs(new RefSpec("HEAD:refs/for/master")).call();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index 0661466..0f394aa7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -35,7 +35,7 @@
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.RevisionJson;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
@@ -54,7 +54,7 @@
     return submitWholeTopicEnabledConfig();
   }
 
-  @Inject private ChangeJson.Factory changeJsonFactory;
+  @Inject private RevisionJson.Factory revisionJsonFactory;
 
   @Inject private DynamicSet<ActionVisitor> actionVisitors;
 
@@ -394,7 +394,7 @@
 
     // ...via ChangeJson directly.
     ChangeData cd = changeDataFactory.create(db, project, changeId);
-    changeJsonFactory.create(opts).getRevisionInfo(cd, cd.patchSet(new PatchSet.Id(changeId, 1)));
+    revisionJsonFactory.create(opts).getRevisionInfo(cd, cd.patchSet(new PatchSet.Id(changeId, 1)));
   }
 
   private void visitedCurrentRevisionActionsAssertions(
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
index 06b67d8..257c88b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ReviewerState;
@@ -319,6 +320,42 @@
     }
   }
 
+  @Test
+  public void addExistingReviewerByEmailShortCircuits() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = "nonexisting@example.com";
+    input.state = ReviewerState.REVIEWER;
+
+    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+    assertThat(result.reviewers).hasSize(1);
+    ReviewerInfo info = result.reviewers.get(0);
+    assertThat(info._accountId).isNull();
+    assertThat(info.email).isEqualTo(input.reviewer);
+
+    assertThat(gApi.changes().id(r.getChangeId()).addReviewer(input).reviewers).isEmpty();
+  }
+
+  @Test
+  public void addExistingCcByEmailShortCircuits() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = "nonexisting@example.com";
+    input.state = ReviewerState.CC;
+    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+    assertThat(result.ccs).hasSize(1);
+    AccountInfo info = result.ccs.get(0);
+    assertThat(info._accountId).isNull();
+    assertThat(info.email).isEqualTo(input.reviewer);
+
+    assertThat(gApi.changes().id(r.getChangeId()).addReviewer(input).ccs).isEmpty();
+  }
+
   private static String toRfcAddressString(AccountInfo info) {
     return (new Address(info.name, info.email)).toString();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index 2259999..d1f4d84 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
@@ -48,7 +49,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.restapi.change.PostReviewers;
+import com.google.gerrit.server.change.ReviewerAdder;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.stream.JsonReader;
 import java.util.ArrayList;
@@ -68,8 +69,8 @@
     String largeGroup = createGroup("largeGroup");
     String mediumGroup = createGroup("mediumGroup");
 
-    int largeGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS + 1;
-    int mediumGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
+    int largeGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS + 1;
+    int mediumGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
     List<TestAccount> users = createAccounts(largeGroupSize, "addGroupAsReviewer");
     List<String> largeGroupUsernames = new ArrayList<>(mediumGroupSize);
     for (TestAccount u : users) {
@@ -470,8 +471,8 @@
 
   @Test
   public void reviewAndAddGroupReviewers() throws Exception {
-    int largeGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS + 1;
-    int mediumGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
+    int largeGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS + 1;
+    int mediumGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
     List<TestAccount> users = createAccounts(largeGroupSize, "reviewAndAddGroupReviewers");
     List<String> usernames = new ArrayList<>(largeGroupSize);
     for (TestAccount u : users) {
@@ -784,6 +785,40 @@
     gApi.changes().id(r.getChangeId()).reviewer(user.email).remove();
   }
 
+  @Test
+  public void addExistingReviewerShortCircuits() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = user.email;
+    input.state = ReviewerState.REVIEWER;
+
+    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+    assertThat(result.reviewers).hasSize(1);
+    ReviewerInfo info = result.reviewers.get(0);
+    assertThat(info._accountId).isEqualTo(user.id.get());
+
+    assertThat(gApi.changes().id(r.getChangeId()).addReviewer(input).reviewers).isEmpty();
+  }
+
+  @Test
+  public void addExistingCcShortCircuits() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = user.email;
+    input.state = ReviewerState.CC;
+
+    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+    assertThat(result.ccs).hasSize(1);
+    AccountInfo info = result.ccs.get(0);
+    assertThat(info._accountId).isEqualTo(user.id.get());
+
+    assertThat(gApi.changes().id(r.getChangeId()).addReviewer(input).ccs).isEmpty();
+  }
+
   private void assertThatUserIsOnlyReviewer(String changeId) throws Exception {
     AccountInfo userInfo = new AccountInfo(user.fullName, user.emailAddress.getEmail());
     userInfo._accountId = user.id.get();
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 9645a94..2daccc0 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -566,15 +566,64 @@
     addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNone(batch());
   }
 
+  private void addNonUserReviewerByEmailInNoteDb(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    addReviewer(adder, sc.changeId, sc.owner, "nonexistent@example.com");
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to("nonexistent@example.com")
+        .cc(sc.reviewer)
+        .cc(sc.ccerByEmail, sc.reviewerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addNonUserReviewerByEmailInNoteDbSingly() throws Exception {
+    addNonUserReviewerByEmailInNoteDb(singly(ReviewerState.REVIEWER));
+  }
+
+  @Test
+  public void addNonUserReviewerByEmailInNoteDbBatch() throws Exception {
+    addNonUserReviewerByEmailInNoteDb(batch(ReviewerState.REVIEWER));
+  }
+
+  private void addNonUserCcByEmailInNoteDb(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    addReviewer(adder, sc.changeId, sc.owner, "nonexistent@example.com");
+    assertThat(sender)
+        .sent("newchange", sc)
+        .cc("nonexistent@example.com")
+        .cc(sc.reviewer)
+        .cc(sc.ccerByEmail, sc.reviewerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addNonUserCcByEmailInNoteDbSingly() throws Exception {
+    addNonUserCcByEmailInNoteDb(singly(ReviewerState.CC));
+  }
+
+  @Test
+  public void addNonUserCcByEmailInNoteDbBatch() throws Exception {
+    addNonUserCcByEmailInNoteDb(batch(ReviewerState.CC));
+  }
+
   private interface Adder {
     void addReviewer(String changeId, String reviewer, @Nullable NotifyHandling notify)
         throws Exception;
   }
 
   private Adder singly() {
+    return singly(ReviewerState.REVIEWER);
+  }
+
+  private Adder singly(ReviewerState reviewerState) {
     return (String changeId, String reviewer, @Nullable NotifyHandling notify) -> {
       AddReviewerInput in = new AddReviewerInput();
       in.reviewer = reviewer;
+      in.state = reviewerState;
       if (notify != null) {
         in.notify = notify;
       }
@@ -583,9 +632,13 @@
   }
 
   private Adder batch() {
+    return batch(ReviewerState.REVIEWER);
+  }
+
+  private Adder batch(ReviewerState reviewerState) {
     return (String changeId, String reviewer, @Nullable NotifyHandling notify) -> {
       ReviewInput in = ReviewInput.noScore();
-      in.reviewer(reviewer);
+      in.reviewer(reviewer, reviewerState, false);
       if (notify != null) {
         in.notify = notify;
       }
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 543ae1f..4cea41a 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 543ae1f0a24dda61d4f36b173b9bddfd52ead3b9
+Subproject commit 4cea41a51ff7ce98bc18e4c01577f99a93931562
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index e3596e7..b254182 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -91,22 +91,36 @@
     },
 
     _handleServerError(e) {
-      Promise.all([
-        e.detail.response.text(), this._getLoggedIn(),
-      ]).then(values => {
-        const text = values[0];
-        const loggedIn = values[1];
-        if (e.detail.response.status === 403 &&
-            loggedIn &&
-            text === AUTHENTICATION_REQUIRED) {
-          // The app was logged at one point and is now getting auth errors.
-          // This indicates the auth token is no longer valid.
-          this._handleAuthError();
-        } else if (!this._shouldSuppressError(text)) {
-          this._showErrorDialog('Server error: ' + text);
-        }
-        console.error(text);
-      });
+      const {request, response} = e.detail;
+      Promise.all([response.text(), this._getLoggedIn()])
+          .then(([errorText, loggedIn]) => {
+            const url = request && (request.anonymizedUrl || request.url);
+            const {status, statusText} = response;
+            if (response.status === 403 &&
+                loggedIn &&
+                errorText === AUTHENTICATION_REQUIRED) {
+              // The app was logged at one point and is now getting auth errors.
+              // This indicates the auth token is no longer valid.
+              this._handleAuthError();
+            } else if (!this._shouldSuppressError(errorText)) {
+              this._showErrorDialog(this._constructServerErrorMsg({
+                status,
+                statusText,
+                errorText,
+                url,
+              }));
+            }
+            console.error(errorText);
+          });
+    },
+
+    _constructServerErrorMsg({errorText, status, statusText, url}) {
+      let err = `Error ${status}`;
+      if (statusText) { err += ` (${statusText})`; }
+      if (errorText || url) { err += ': '; }
+      if (errorText) { err += errorText; }
+      if (url) { err += `\nEndpoint: ${url}`; }
+      return err;
     },
 
     _handleShowAlert(e) {
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index e28b979..f92feae 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -86,7 +86,7 @@
           'Log in is required to perform that action.', 'Log in.'));
     });
 
-    test('show normal server error', done => {
+    test('show normal Error', done => {
       const showErrorStub = sandbox.stub(element, '_showErrorDialog');
       const textSpy = sandbox.spy(() => { return Promise.resolve('ZOMG'); });
       element.fire('server-error', {response: {status: 500, text: textSpy}});
@@ -98,11 +98,32 @@
       ]).then(() => {
         assert.isTrue(showErrorStub.calledOnce);
         assert.isTrue(showErrorStub.lastCall.calledWithExactly(
-            'Server error: ZOMG'));
+            'Error 500: ZOMG'));
         done();
       });
     });
 
+    test('_constructServerErrorMsg', () => {
+      const errorText = 'change conflicts';
+      const status = 409;
+      const statusText = 'Conflict';
+      const url = '/my/test/url';
+
+      assert.equal(element._constructServerErrorMsg({status}),
+          'Error 409');
+      assert.equal(element._constructServerErrorMsg({status, url}),
+          'Error 409: \nEndpoint: /my/test/url');
+      assert.equal(element._constructServerErrorMsg({status, statusText, url}),
+          'Error 409 (Conflict): \nEndpoint: /my/test/url');
+      assert.equal(element._constructServerErrorMsg({
+        status,
+        statusText,
+        errorText,
+        url,
+      }), 'Error 409 (Conflict): change conflicts' +
+          '\nEndpoint: /my/test/url');
+    });
+
     test('suppress TOO_MANY_FILES error', done => {
       const showAlertStub = sandbox.stub(element, '_showAlert');
       const textSpy = sandbox.spy(() => {
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
index 912deb9..29d554e 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
@@ -17,6 +17,7 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 
+<link rel="import" href="../../shared/gr-avatar/gr-avatar.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
@@ -26,10 +27,35 @@
 
 <dom-module id="gr-account-info">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      gr-avatar {
+        height: 10em;
+        width: 10em;
+        margin-right: .15em;
+        vertical-align: -.25em;
+      }
+      .hide {
+        display: none;
+      }
+    </style>
     <style include="gr-form-styles"></style>
     <div class="gr-form-styles">
       <section>
+        <span class="title"></span>
+        <span class="value">
+          <gr-avatar account="[[_account]]"
+              image-size="32"></gr-avatar>
+        </span>
+      </section>
+      <section class$="[[_hideAvatarChangeUrl(_avatarChangeUrl)]]">
+        <span class="title"></span>
+        <span class="value">
+          <a href$="[[_avatarChangeUrl]]">
+            Change avatar
+          </a>
+        </span>
+      </section>
+      <section>
         <span class="title">ID</span>
         <span class="value">[[_account._account_id]]</span>
       </section>
@@ -59,6 +85,7 @@
               disabled="[[_saving]]"
               on-keydown="_handleKeydown"
               bind-value="{{_username}}">
+        </span>
       </section>
       <section id="nameSection">
         <span class="title">Full name</span>
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
index 3ad151a..fcc99aa 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
@@ -62,6 +62,10 @@
         type: String,
         observer: '_usernameChanged',
       },
+      _avatarChangeUrl: {
+        type: String,
+        value: '',
+      },
     },
 
     observers: [
@@ -89,6 +93,10 @@
         this._username = account.username;
       }));
 
+      promises.push(this.$.restAPI.getAvatarChangeUrl().then(url => {
+        this._avatarChangeUrl = url;
+      }));
+
       return Promise.all(promises).then(() => {
         this._loading = false;
       });
@@ -167,5 +175,13 @@
         this.save();
       }
     },
+
+    _hideAvatarChangeUrl(avatarChangeUrl) {
+      if (!avatarChangeUrl) {
+        return 'hide';
+      }
+
+      return '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
index d5682e0..f91277a 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
@@ -330,5 +330,11 @@
 
       assert.isTrue(element._hasUsernameChange);
     });
+
+    test('_hideAvatarChangeUrl', () => {
+      assert.equal(element._hideAvatarChangeUrl(''), 'hide');
+
+      assert.equal(element._hideAvatarChangeUrl('https://example.com'), '');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index f6c5954..b4a36b0 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -298,7 +298,7 @@
             req.errFn.call(null, response);
             return;
           }
-          this.fire('server-error', {response});
+          this.fire('server-error', {request: req, response});
           return;
         }
         return response && this.getResponseObject(response);
@@ -822,6 +822,18 @@
       });
     },
 
+    getAvatarChangeUrl() {
+      return this._fetchSharedCacheURL({
+        url: '/accounts/self/avatar.change.url',
+        reportUrlAsIs: true,
+        errFn: resp => {
+          if (!resp || resp.status === 403) {
+            this._cache['/accounts/self/avatar.change.url'] = null;
+          }
+        },
+      });
+    },
+
     getExternalIds() {
       return this._fetchJSON({
         url: '/accounts/self/external.ids',
@@ -1307,7 +1319,7 @@
             if (opt_errFn) {
               opt_errFn.call(null, response);
             } else {
-              this.fire('server-error', {response});
+              this.fire('server-error', {request: req, response});
             }
             return;
           }
@@ -2086,7 +2098,7 @@
           if (req.errFn) {
             return req.errFn.call(undefined, response);
           }
-          this.fire('server-error', {response});
+          this.fire('server-error', {request: fetchReq, response});
         }
         return response;
       }).catch(err => {