Merge changes from topic "group-resolver"

* changes:
  AddReviewersOp: Get user and ReviewDb from context
  Move ReviewerAdder and its dependencies out of restapi package
  Rename PostReviewers{Op,Email} to AddReviewers{Op,Email}
  Move most methods from AccountsCollection to AccountResolver
  Factor a non-restapi GroupResolver out of GroupsCollection
  Move common reviewer addition code out of PostReviewers
  PostReviewersOp: Pass correct to/CC arguments to emailReviewers
  AbstractNotificationTest: Improve readability of failure message
  Add push tests for auto-adding forged identities as reviewers
  Add tests for re-adding the same reviewers
  Add notification tests for adding reviewer/CC by email
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/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 9dc9359..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,24 +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 java.util.function.Supplier;
-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;
 
 /**
  * Produces {@link ChangeInfo} (which is serialized to JSON afterwards) from {@link ChangeData}.
@@ -184,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,
@@ -252,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;
@@ -288,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) {
@@ -398,10 +311,68 @@
     return format(cd, Optional.empty(), true, changeInfoSupplier);
   }
 
-  private ChangeInfo format(
-      ChangeData cd, Optional<PatchSet.Id> limitToPsId, boolean fillAccountLoader)
-      throws OrmException {
-    return format(cd, limitToPsId, fillAccountLoader, ChangeInfo::new);
+  public ChangeInfo format(RevisionResource rsrc) throws OrmException {
+    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
+    return format(cd, Optional.of(rsrc.getPatchSet().getId()), true, ChangeInfo::new);
+  }
+
+  public List<List<ChangeInfo>> formatQueryResults(List<QueryResult<ChangeData>> in)
+      throws PermissionBackendException {
+    try (Timer0.Context ignored = metrics.formatQueryResultsLatency.start()) {
+      accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
+      List<List<ChangeInfo>> res = new ArrayList<>(in.size());
+      Map<Change.Id, ChangeInfo> cache = Maps.newHashMapWithExpectedSize(in.size());
+      for (QueryResult<ChangeData> r : in) {
+        List<ChangeInfo> infos = toChangeInfos(r.entities(), cache);
+        infos.forEach(c -> cache.put(new Change.Id(c._number), c));
+        if (!infos.isEmpty() && r.more()) {
+          infos.get(infos.size() - 1)._moreChanges = true;
+        }
+        res.add(infos);
+      }
+      accountLoader.fill();
+      return res;
+    }
+  }
+
+  public List<ChangeInfo> formatChangeDatas(Collection<ChangeData> in)
+      throws OrmException, PermissionBackendException {
+    accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
+    ensureLoaded(in);
+    List<ChangeInfo> out = new ArrayList<>(in.size());
+    for (ChangeData cd : in) {
+      out.add(format(cd, Optional.empty(), false, ChangeInfo::new));
+    }
+    accountLoader.fill();
+    return out;
+  }
+
+  private static Collection<SubmitRequirementInfo> requirementsFor(ChangeData cd) {
+    Collection<SubmitRequirementInfo> reqInfos = new ArrayList<>();
+    for (SubmitRecord submitRecord : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
+      if (submitRecord.requirements == null) {
+        continue;
+      }
+      for (SubmitRequirement requirement : submitRecord.requirements) {
+        reqInfos.add(requirementToInfo(requirement, submitRecord.status));
+      }
+    }
+    return reqInfos;
+  }
+
+  private static SubmitRequirementInfo requirementToInfo(SubmitRequirement req, Status status) {
+    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(
@@ -432,59 +403,6 @@
     }
   }
 
-  public ChangeInfo format(RevisionResource rsrc) throws OrmException {
-    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
-    return format(cd, Optional.of(rsrc.getPatchSet().getId()), true);
-  }
-
-  public List<List<ChangeInfo>> formatQueryResults(List<QueryResult<ChangeData>> in)
-      throws PermissionBackendException {
-    try (Timer0.Context ignored = metrics.formatQueryResultsLatency.start()) {
-      accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-      List<List<ChangeInfo>> res = new ArrayList<>(in.size());
-      Map<Change.Id, ChangeInfo> cache = Maps.newHashMapWithExpectedSize(in.size());
-      for (QueryResult<ChangeData> r : in) {
-        List<ChangeInfo> infos = toChangeInfos(r.entities(), cache);
-        infos.forEach(c -> cache.put(new Change.Id(c._number), c));
-        if (!infos.isEmpty() && r.more()) {
-          infos.get(infos.size() - 1)._moreChanges = true;
-        }
-        res.add(infos);
-      }
-      accountLoader.fill();
-      return res;
-    }
-  }
-
-  public List<ChangeInfo> formatChangeDatas(Collection<ChangeData> in)
-      throws OrmException, PermissionBackendException {
-    accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-    ensureLoaded(in);
-    List<ChangeInfo> out = new ArrayList<>(in.size());
-    for (ChangeData cd : in) {
-      out.add(format(cd, Optional.empty(), false));
-    }
-    accountLoader.fill();
-    return out;
-  }
-
-  private static Collection<SubmitRequirementInfo> requirementsFor(ChangeData cd) {
-    Collection<SubmitRequirementInfo> reqInfos = new ArrayList<>();
-    for (SubmitRecord submitRecord : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
-      if (submitRecord.requirements == null) {
-        continue;
-      }
-      for (SubmitRequirement requirement : submitRecord.requirements) {
-        reqInfos.add(requirementToInfo(requirement, submitRecord.status));
-      }
-    }
-    return reqInfos;
-  }
-
-  private static SubmitRequirementInfo requirementToInfo(SubmitRequirement req, Status status) {
-    return new SubmitRequirementInfo(status.name(), req.fallbackText(), req.type(), req.data());
-  }
-
   private void ensureLoaded(Iterable<ChangeData> all) throws OrmException {
     if (lazyLoad) {
       ChangeData.ensureChangeLoaded(all);
@@ -511,51 +429,21 @@
   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;
     }
   }
 
@@ -674,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)) {
@@ -684,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();
       }
 
@@ -719,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) {
@@ -780,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()) {
@@ -993,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()) {
@@ -1305,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;
@@ -1373,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
@@ -1575,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/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/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/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/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/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/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/GitmodulesIT.java b/javatests/com/google/gerrit/acceptance/git/GitmodulesIT.java
similarity index 100%
rename from gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/GitmodulesIT.java
rename to javatests/com/google/gerrit/acceptance/git/GitmodulesIT.java
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/plugins/reviewnotes b/plugins/reviewnotes
index 543ae1f..4cea41a 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 543ae1f0a24dda61d4f36b173b9bddfd52ead3b9
+Subproject commit 4cea41a51ff7ce98bc18e4c01577f99a93931562