Merge "Use --primary-text-color in account dropdown"
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
index d542a0b..40e022d 100644
--- a/.settings/org.eclipse.jdt.core.prefs
+++ b/.settings/org.eclipse.jdt.core.prefs
@@ -17,6 +17,7 @@
 org.eclipse.jdt.core.compiler.debug.localVariable=generate
 org.eclipse.jdt.core.compiler.debug.sourceFile=generate
 org.eclipse.jdt.core.compiler.doc.comment.support=enabled
+org.eclipse.jdt.core.compiler.problem.APILeak=warning
 org.eclipse.jdt.core.compiler.problem.annotationSuperInterface=ignore
 org.eclipse.jdt.core.compiler.problem.assertIdentifier=error
 org.eclipse.jdt.core.compiler.problem.autoboxing=ignore
@@ -91,6 +92,7 @@
 org.eclipse.jdt.core.compiler.problem.suppressWarnings=enabled
 org.eclipse.jdt.core.compiler.problem.syntacticNullAnalysisForFields=disabled
 org.eclipse.jdt.core.compiler.problem.syntheticAccessEmulation=ignore
+org.eclipse.jdt.core.compiler.problem.terminalDeprecation=warning
 org.eclipse.jdt.core.compiler.problem.typeParameterHiding=warning
 org.eclipse.jdt.core.compiler.problem.unavoidableGenericTypeProblems=enabled
 org.eclipse.jdt.core.compiler.problem.uncheckedTypeOperation=warning
diff --git a/WORKSPACE b/WORKSPACE
index d482577..15d8651 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -680,8 +680,8 @@
 
 maven_jar(
     name = "junit",
-    artifact = "junit:junit:4.11",
-    sha1 = "4e031bb61df09069aeb2bffb4019e7a5034a4ee0",
+    artifact = "junit:junit:4.12",
+    sha1 = "2973d150c0dc1fefe998f834810d68f278ea58ec",
 )
 
 maven_jar(
@@ -697,18 +697,18 @@
     sha1 = "4785a3c21320980282f9f33d0d1264a69040538f",
 )
 
-TRUTH_VERS = "0.39"
+TRUTH_VERS = "0.40"
 
 maven_jar(
     name = "truth",
     artifact = "com.google.truth:truth:" + TRUTH_VERS,
-    sha1 = "bd1bf5706ff34eb7ff80fef8b0c4320f112ef899",
+    sha1 = "0d74e716afec045cc4a178dbbfde2a8314ae5574",
 )
 
 maven_jar(
     name = "truth-java8-extension",
     artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
-    sha1 = "1499bc88cda9d674afb30da9813b44bcd4512d0d",
+    sha1 = "636e49d675bc28e0b3ae0edd077d6acbbb159166",
 )
 
 # When bumping the easymock version number, make sure to also move powermock to a compatible version
diff --git a/gerrit-gwtui/BUILD b/gerrit-gwtui/BUILD
index a6c9763..56ac0ea 100644
--- a/gerrit-gwtui/BUILD
+++ b/gerrit-gwtui/BUILD
@@ -34,8 +34,8 @@
         "//java/com/google/gerrit/common:client",
         "//java/com/google/gerrit/extensions:client",
         "//lib:junit",
-        "//lib:truth",
         "//lib/gwt:dev",
         "//lib/gwt:user",
+        "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index acd5130a..9587860 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -76,9 +76,8 @@
         "//java/com/google/gerrit/server/group/testing",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
         "//lib:jimfs",
-        "//lib:truth",
-        "//lib:truth-java8-extension",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/httpcomponents:fluent-hc",
@@ -88,6 +87,8 @@
         "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/log:impl_log4j",
         "//lib/log:log4j",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
         "//prolog:gerrit-prolog-common",
     ],
     visibility = ["//visibility:public"],
diff --git a/java/com/google/gerrit/common/data/testing/BUILD b/java/com/google/gerrit/common/data/testing/BUILD
index 83f1c06..3899e39 100644
--- a/java/com/google/gerrit/common/data/testing/BUILD
+++ b/java/com/google/gerrit/common/data/testing/BUILD
@@ -6,6 +6,6 @@
     deps = [
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/reviewdb:server",
-        "//lib:truth",
+        "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index 0a06c31..58a298e 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.elasticsearch;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.index.change.ChangeField.APPROVAL_CODEC;
-import static com.google.gerrit.server.index.change.ChangeField.CHANGE_CODEC;
-import static com.google.gerrit.server.index.change.ChangeField.PATCH_SET_CODEC;
+import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
+import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.CHANGE_CODEC;
+import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
 import static java.nio.charset.StandardCharsets.UTF_8;
diff --git a/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java b/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
index b736262..84b6a04 100644
--- a/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
+++ b/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
@@ -14,9 +14,20 @@
 
 package com.google.gerrit.extensions.auth.oauth;
 
-import java.io.Serializable;
+import static com.google.common.base.Preconditions.checkNotNull;
 
-/* OAuth token */
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import java.io.Serializable;
+import java.util.Objects;
+
+/**
+ * OAuth token.
+ *
+ * <p>Only implements {@link Serializable} for backwards compatibility; new extensions should not
+ * depend on the serialized format.
+ */
 public class OAuthToken implements Serializable {
 
   private static final long serialVersionUID = 1L;
@@ -32,8 +43,9 @@
   private final long expiresAt;
 
   /**
-   * The identifier of the OAuth provider that issued this token in the form
-   * <tt>"plugin-name:provider-name"</tt>, or {@code null}.
+   * The identifier of the OAuth provider that issued this token in the form {@code
+   * "plugin-name:provider-name"}, or {@code null}. The empty string {@code ""} is treated the same
+   * as {@code null}.
    */
   private final String providerId;
 
@@ -41,12 +53,13 @@
     this(token, secret, raw, Long.MAX_VALUE, null);
   }
 
-  public OAuthToken(String token, String secret, String raw, long expiresAt, String providerId) {
-    this.token = token;
-    this.secret = secret;
-    this.raw = raw;
+  public OAuthToken(
+      String token, String secret, String raw, long expiresAt, @Nullable String providerId) {
+    this.token = checkNotNull(token, "token");
+    this.secret = checkNotNull(secret, "secret");
+    this.raw = checkNotNull(raw, "raw");
     this.expiresAt = expiresAt;
-    this.providerId = providerId;
+    this.providerId = Strings.emptyToNull(providerId);
   }
 
   public String getToken() {
@@ -69,7 +82,37 @@
     return System.currentTimeMillis() > expiresAt;
   }
 
+  @Nullable
   public String getProviderId() {
     return providerId;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof OAuthToken)) {
+      return false;
+    }
+    OAuthToken t = (OAuthToken) o;
+    return token.equals(t.token)
+        && secret.equals(t.secret)
+        && raw.equals(t.raw)
+        && expiresAt == t.expiresAt
+        && Objects.equals(providerId, t.providerId);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(token, secret, raw, expiresAt, providerId);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("token", token)
+        .add("secret", secret)
+        .add("raw", raw)
+        .add("expiresAt", expiresAt)
+        .add("providerId", providerId)
+        .toString();
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/BUILD b/java/com/google/gerrit/extensions/common/testing/BUILD
index 82dd425..94fecbf 100644
--- a/java/com/google/gerrit/extensions/common/testing/BUILD
+++ b/java/com/google/gerrit/extensions/common/testing/BUILD
@@ -6,7 +6,7 @@
     deps = [
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/truth",
-        "//lib:truth",
         "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/extensions/registration/DynamicMap.java b/java/com/google/gerrit/extensions/registration/DynamicMap.java
index e0db0c7..7178a16 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicMap.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicMap.java
@@ -83,6 +83,11 @@
     binder.bind(key).toProvider(new DynamicMapProvider<>(member)).in(Scopes.SINGLETON);
   }
 
+  /** Returns an empty DynamicMap instance * */
+  public static <T> DynamicMap<T> emptyMap() {
+    return new PrivateInternals_DynamicMapImpl<>();
+  }
+
   final ConcurrentMap<NamePair, Provider<T>> items;
 
   DynamicMap() {
@@ -188,8 +193,8 @@
     private final String exportName;
 
     NamePair(String pn, String en) {
-      this.pluginName = pn;
-      this.exportName = en;
+      pluginName = pn;
+      exportName = en;
     }
 
     @Override
@@ -206,8 +211,4 @@
       return false;
     }
   }
-
-  public static <T> DynamicMap<T> emptyMap() {
-    return new DynamicMap<T>() {};
-  }
 }
diff --git a/java/com/google/gerrit/extensions/registration/DynamicSet.java b/java/com/google/gerrit/extensions/registration/DynamicSet.java
index 5cdf267..7ffb86d 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSet.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSet.java
@@ -139,7 +139,7 @@
   }
 
   public DynamicSet() {
-    this(Collections.<AtomicReference<Provider<T>>>emptySet());
+    this(Collections.emptySet());
   }
 
   @Override
diff --git a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
index 50aed7d..1973f70 100644
--- a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
+++ b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.registration;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.inject.Key;
 import com.google.inject.Provider;
@@ -31,6 +33,7 @@
    * @return handle to remove the item at a later point in time.
    */
   public RegistrationHandle put(String pluginName, String exportName, Provider<T> item) {
+    checkNotNull(item);
     final NamePair key = new NamePair(pluginName, exportName);
     items.put(key, item);
     return new RegistrationHandle() {
@@ -53,6 +56,7 @@
    *     the collection.
    */
   public ReloadableRegistrationHandle<T> put(String pluginName, Key<T> key, Provider<T> item) {
+    checkNotNull(item);
     String exportName = ((Export) key.getAnnotation()).value();
     NamePair np = new NamePair(pluginName, exportName);
     items.put(np, item);
diff --git a/java/com/google/gerrit/extensions/restapi/testing/BUILD b/java/com/google/gerrit/extensions/restapi/testing/BUILD
index d035816..434591e 100644
--- a/java/com/google/gerrit/extensions/restapi/testing/BUILD
+++ b/java/com/google/gerrit/extensions/restapi/testing/BUILD
@@ -6,6 +6,6 @@
     deps = [
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/truth",
-        "//lib:truth",
+        "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/git/testing/BUILD b/java/com/google/gerrit/git/testing/BUILD
index 0b83560..4900339 100644
--- a/java/com/google/gerrit/git/testing/BUILD
+++ b/java/com/google/gerrit/git/testing/BUILD
@@ -7,8 +7,8 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
-        "//lib:truth",
-        "//lib:truth-java8-extension",
         "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
     ],
 )
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index e487a54..4b92ec3 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -259,10 +259,10 @@
 
     if (accountStates.size() > 1) {
       StringBuilder msg = new StringBuilder();
-      msg.append("GPG key ").append(extIdKey.get()).append(" associated with multiple accounts: ");
-      Joiner.on(", ")
-          .appendTo(msg, Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
-      log.error(msg.toString());
+      msg.append("GPG key ")
+          .append(extIdKey.get())
+          .append(" associated with multiple accounts: ")
+          .append(Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
       throw new IllegalStateException(msg.toString());
     }
 
diff --git a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
index 55bd4d5..6174644 100644
--- a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -167,6 +167,8 @@
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     } catch (AuthenticationFailedException e) {
+      // This exception is thrown if the user provided wrong credentials, we don't need to log a
+      // stacktrace for it.
       log.warn(authenticationFailedMsg(username, req) + ": " + e.getMessage());
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
diff --git a/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java b/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
index 24ba4ac..4671475 100644
--- a/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.account.AccountUserNameException;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.AuthenticationFailedException;
 import com.google.gerrit.server.auth.AuthenticationUnavailableException;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
@@ -126,10 +127,16 @@
     } catch (AuthenticationUnavailableException e) {
       sendForm(req, res, "Authentication unavailable at this time.");
       return;
-    } catch (AccountException e) {
-      log.info(String.format("'%s' failed to sign in: %s", username, e.getMessage()));
+    } catch (AuthenticationFailedException e) {
+      // This exception is thrown if the user provided wrong credentials, we don't need to log a
+      // stacktrace for it.
+      log.warn("'{}' failed to sign in: {}", username, e.getMessage());
       sendForm(req, res, "Invalid username or password.");
       return;
+    } catch (AccountException e) {
+      log.warn("'{}' failed to sign in", username, e);
+      sendForm(req, res, "Authentication failed.");
+      return;
     } catch (RuntimeException e) {
       log.error("LDAP authentication failed", e);
       sendForm(req, res, "Authentication unavailable at this time.");
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index 5b60a36f..cc22d24 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -659,7 +659,7 @@
                   dst.close();
                 }
               } catch (IOException e) {
-                log.debug("Unexpected error copying input to CGI", e);
+                log.error("Unexpected error copying input to CGI", e);
               }
             },
             "Gitweb-InputFeeder")
@@ -669,14 +669,19 @@
   private void copyStderrToLog(InputStream in) {
     new Thread(
             () -> {
+              StringBuilder b = new StringBuilder();
               try (BufferedReader br =
                   new BufferedReader(new InputStreamReader(in, ISO_8859_1.name()))) {
                 String line;
                 while ((line = br.readLine()) != null) {
-                  log.error("CGI: " + line);
+                  if (b.length() > 0) {
+                    b.append('\n');
+                  }
+                  b.append("CGI: ").append(line);
                 }
+                log.error(b.toString());
               } catch (IOException e) {
-                log.debug("Unexpected error copying stderr from CGI", e);
+                log.error("Unexpected error copying stderr from CGI", e);
               }
             },
             "Gitweb-ErrorLogger")
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 6cbb357..690d1ac 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -79,6 +79,7 @@
 import com.google.gerrit.server.plugins.PluginModule;
 import com.google.gerrit.server.plugins.PluginRestApiModule;
 import com.google.gerrit.server.project.DefaultProjectNameLockManager;
+import com.google.gerrit.server.restapi.RestApiModule;
 import com.google.gerrit.server.restapi.config.RestCacheAdminModule;
 import com.google.gerrit.server.schema.DataSourceModule;
 import com.google.gerrit.server.schema.DataSourceProvider;
@@ -359,6 +360,7 @@
     modules.add(new PluginModule());
     modules.add(new PluginRestApiModule());
 
+    modules.add(new RestApiModule());
     modules.add(new RestCacheAdminModule());
     modules.add(new GpgModule(config));
     modules.add(new StartupChecks.Module());
diff --git a/java/com/google/gerrit/httpd/raw/BazelBuild.java b/java/com/google/gerrit/httpd/raw/BazelBuild.java
index 85453fb..f52792c 100644
--- a/java/com/google/gerrit/httpd/raw/BazelBuild.java
+++ b/java/com/google/gerrit/httpd/raw/BazelBuild.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.base.Joiner;
 import com.google.common.escape.Escaper;
 import com.google.common.html.HtmlEscapers;
 import com.google.common.io.ByteStreams;
@@ -62,7 +63,8 @@
     try {
       status = rebuild.waitFor();
     } catch (InterruptedException e) {
-      throw new InterruptedIOException("interrupted waiting for " + proc.toString());
+      throw new InterruptedIOException(
+          "interrupted waiting for: " + Joiner.on(' ').join(proc.command()));
     }
     if (status != 0) {
       log.warn("build failed: " + new String(out, UTF_8));
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 468aa67..c8f8fff 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -16,11 +16,11 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
+import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
+import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.CHANGE_CODEC;
+import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
-import static com.google.gerrit.server.index.change.ChangeField.APPROVAL_CODEC;
-import static com.google.gerrit.server.index.change.ChangeField.CHANGE_CODEC;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
-import static com.google.gerrit.server.index.change.ChangeField.PATCH_SET_CODEC;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
diff --git a/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java b/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
index 7256e8c..bc2846a 100644
--- a/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
+++ b/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
@@ -41,7 +41,7 @@
             return new OperatingSystemMXBeanProvider(sys);
           }
         } catch (ReflectiveOperationException e) {
-          log.debug(String.format("No implementation for %s: %s", name, e.getMessage()));
+          log.debug("No implementation for {}", name, e);
         }
       }
       log.warn("No implementation of UnixOperatingSystemMXBean found");
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 417b00d..730f219 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -89,6 +89,7 @@
 import com.google.gerrit.server.plugins.PluginModule;
 import com.google.gerrit.server.plugins.PluginRestApiModule;
 import com.google.gerrit.server.project.DefaultProjectNameLockManager;
+import com.google.gerrit.server.restapi.RestApiModule;
 import com.google.gerrit.server.restapi.config.RestCacheAdminModule;
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.InMemoryAccountPatchReviewStore;
@@ -433,6 +434,7 @@
       modules.add(new SmtpEmailSender.Module());
     }
     modules.add(new SignedTokenEmailTokenVerifier.Module());
+    modules.add(new RestApiModule());
     modules.add(new PluginRestApiModule());
     modules.add(new RestCacheAdminModule());
     modules.add(new GpgModule(config));
diff --git a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index b6eac05..25a28a4 100644
--- a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -69,13 +69,9 @@
 import org.eclipse.jetty.util.thread.QueuedThreadPool;
 import org.eclipse.jetty.util.thread.ThreadPool;
 import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 @Singleton
 public class JettyServer {
-  private static final Logger log = LoggerFactory.getLogger(JettyServer.class);
-
   static class Lifecycle implements LifecycleListener {
     private final JettyServer server;
     private final Config cfg;
@@ -425,9 +421,8 @@
             "/*",
             EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC));
       } catch (Throwable e) {
-        String errorMessage = "Unable to instantiate front-end HTTP Filter " + filterClassName;
-        log.error(errorMessage, e);
-        throw new IllegalArgumentException(errorMessage, e);
+        throw new IllegalArgumentException(
+            "Unable to instantiate front-end HTTP Filter " + filterClassName, e);
       }
     }
 
diff --git a/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java b/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
index c1112ae..5073200 100644
--- a/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
+++ b/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
@@ -30,7 +30,6 @@
 import org.slf4j.LoggerFactory;
 
 public class AllProjectsConfig extends VersionedMetaDataOnInit {
-
   private static final Logger log = LoggerFactory.getLogger(AllProjectsConfig.class);
 
   private Config cfg;
@@ -65,7 +64,7 @@
     return GroupList.parse(
         new Project.NameKey(project),
         readUTF8(GroupList.FILE_NAME),
-        GroupList.createLoggerSink(GroupList.FILE_NAME, log));
+        error -> log.error("Error parsing file {}: {}", GroupList.FILE_NAME, error.getMessage()));
   }
 
   public void save(String pluginName, String message) throws IOException, ConfigInvalidException {
diff --git a/java/com/google/gerrit/reviewdb/client/ChangeMessage.java b/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
index edc022f..8e397f0 100644
--- a/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
+++ b/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
@@ -149,6 +149,26 @@
   }
 
   @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof ChangeMessage)) {
+      return false;
+    }
+    ChangeMessage m = (ChangeMessage) o;
+    return Objects.equals(key, m.key)
+        && Objects.equals(author, m.author)
+        && Objects.equals(writtenOn, m.writtenOn)
+        && Objects.equals(message, m.message)
+        && Objects.equals(patchset, m.patchset)
+        && Objects.equals(tag, m.tag)
+        && Objects.equals(realAuthor, m.realAuthor);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(key, author, writtenOn, message, patchset, tag, realAuthor);
+  }
+
+  @Override
   public String toString() {
     return "ChangeMessage{"
         + "key="
diff --git a/java/com/google/gerrit/reviewdb/client/PatchSet.java b/java/com/google/gerrit/reviewdb/client/PatchSet.java
index 4536b67..849fd75 100644
--- a/java/com/google/gerrit/reviewdb/client/PatchSet.java
+++ b/java/com/google/gerrit/reviewdb/client/PatchSet.java
@@ -20,6 +20,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 
 /** A single revision of a {@link Change}. */
 public final class PatchSet {
@@ -280,6 +281,26 @@
   }
 
   @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof PatchSet)) {
+      return false;
+    }
+    PatchSet p = (PatchSet) o;
+    return Objects.equals(id, p.id)
+        && Objects.equals(revision, p.revision)
+        && Objects.equals(uploader, p.uploader)
+        && Objects.equals(createdOn, p.createdOn)
+        && Objects.equals(groups, p.groups)
+        && Objects.equals(pushCertificate, p.pushCertificate)
+        && Objects.equals(description, p.description);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(id, revision, uploader, createdOn, groups, pushCertificate, description);
+  }
+
+  @Override
   public String toString() {
     return "[PatchSet " + getId().toString() + "]";
   }
diff --git a/java/com/google/gerrit/reviewdb/server/ReviewDbCodecs.java b/java/com/google/gerrit/reviewdb/server/ReviewDbCodecs.java
new file mode 100644
index 0000000..631e7f5
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/server/ReviewDbCodecs.java
@@ -0,0 +1,34 @@
+// 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.reviewdb.server;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gwtorm.protobuf.CodecFactory;
+import com.google.gwtorm.protobuf.ProtobufCodec;
+
+/** {@link ProtobufCodec} instances for ReviewDb types. */
+public class ReviewDbCodecs {
+  public static final ProtobufCodec<PatchSetApproval> APPROVAL_CODEC =
+      CodecFactory.encoder(PatchSetApproval.class);
+
+  public static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
+
+  public static final ProtobufCodec<PatchSet> PATCH_SET_CODEC =
+      CodecFactory.encoder(PatchSet.class);
+
+  private ReviewDbCodecs() {}
+}
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index 75a9991..e635072 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -116,14 +116,6 @@
     return notes.load().getChangeMessages();
   }
 
-  public Iterable<ChangeMessage> byPatchSet(ReviewDb db, ChangeNotes notes, PatchSet.Id psId)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      return db.changeMessages().byPatchSet(psId);
-    }
-    return notes.load().getChangeMessagesByPatchSet().get(psId);
-  }
-
   public void addChangeMessage(ReviewDb db, ChangeUpdate update, ChangeMessage changeMessage)
       throws OrmException {
     checkState(
diff --git a/java/com/google/gerrit/server/account/VersionedAccountDestinations.java b/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
index a57dc7b..1064546 100644
--- a/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
+++ b/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
@@ -16,8 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.git.ValidationError;
-import com.google.gerrit.server.git.meta.TabFile;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -61,17 +59,15 @@
         String path = p.path;
         if (path.startsWith(prefix)) {
           String label = path.substring(prefix.length());
-          ValidationError.Sink errors = TabFile.createLoggerSink(path, log);
-          destinations.parseLabel(label, readUTF8(path), errors);
+          destinations.parseLabel(
+              label,
+              readUTF8(path),
+              error -> log.error("Error parsing file {}: {}", path, error.getMessage()));
         }
       }
     }
   }
 
-  public ValidationError.Sink createSink(String file) {
-    return ValidationError.createLoggerSink(file, log);
-  }
-
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     throw new UnsupportedOperationException("Cannot yet save destinations");
diff --git a/java/com/google/gerrit/server/account/VersionedAccountQueries.java b/java/com/google/gerrit/server/account/VersionedAccountQueries.java
index b43a65d..b021d24 100644
--- a/java/com/google/gerrit/server/account/VersionedAccountQueries.java
+++ b/java/com/google/gerrit/server/account/VersionedAccountQueries.java
@@ -51,7 +51,9 @@
   protected void onLoad() throws IOException, ConfigInvalidException {
     queryList =
         QueryList.parse(
-            readUTF8(QueryList.FILE_NAME), QueryList.createLoggerSink(QueryList.FILE_NAME, log));
+            readUTF8(QueryList.FILE_NAME),
+            error ->
+                log.error("Error parsing file {}: {}", QueryList.FILE_NAME, error.getMessage()));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 442bc2a..db8ea41 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -118,6 +118,8 @@
    * AuthType#HTTP_LDAP}, and {@link AuthType#LDAP_BIND} usernames.
    *
    * <p>The name {@code gerrit:} was a very poor choice.
+   *
+   * <p>Scheme names must not contain colons (':').
    */
   public static final String SCHEME_GERRIT = "gerrit";
 
@@ -140,6 +142,13 @@
   public abstract static class Key implements Serializable {
     private static final long serialVersionUID = 1L;
 
+    /**
+     * Creates an external ID key.
+     *
+     * @param scheme the scheme name, must not contain colons (':'), can be {@code null}
+     * @param id the external ID, must not contain colons (':')
+     * @return the created external ID key
+     */
     public static Key create(@Nullable String scheme, String id) {
       return new AutoValue_ExternalId_Key(Strings.emptyToNull(scheme), id);
     }
@@ -198,10 +207,28 @@
     }
   }
 
+  /**
+   * Creates an external ID.
+   *
+   * @param scheme the scheme name, must not contain colons (':')
+   * @param id the external ID, must not contain colons (':')
+   * @param accountId the ID of the account to which the external ID belongs
+   * @return the created external ID
+   */
   public static ExternalId create(String scheme, String id, Account.Id accountId) {
     return create(Key.create(scheme, id), accountId, null, null);
   }
 
+  /**
+   * Creates an external ID.
+   *
+   * @param scheme the scheme name, must not contain colons (':')
+   * @param id the external ID, must not contain colons (':')
+   * @param accountId the ID of the account to which the external ID belongs
+   * @param email the email of the external ID, may be {@code null}
+   * @param hashedPassword the hashed password of the external ID, may be {@code null}
+   * @return the created external ID
+   */
   public static ExternalId create(
       String scheme,
       String id,
@@ -222,17 +249,35 @@
   }
 
   public static ExternalId createWithPassword(
-      Key key, Account.Id accountId, @Nullable String email, String plainPassword) {
+      Key key, Account.Id accountId, @Nullable String email, @Nullable String plainPassword) {
     plainPassword = Strings.emptyToNull(plainPassword);
     String hashedPassword =
         plainPassword != null ? HashedPassword.fromPassword(plainPassword).encode() : null;
     return create(key, accountId, email, hashedPassword);
   }
 
-  public static ExternalId createUsername(String id, Account.Id accountId, String plainPassword) {
+  /**
+   * Create a external ID for a username (scheme "username").
+   *
+   * @param id the external ID, must not contain colons (':')
+   * @param accountId the ID of the account to which the external ID belongs
+   * @param plainPassword the plain HTTP password, may be {@code null}
+   * @return the created external ID
+   */
+  public static ExternalId createUsername(
+      String id, Account.Id accountId, @Nullable String plainPassword) {
     return createWithPassword(Key.create(SCHEME_USERNAME, id), accountId, null, plainPassword);
   }
 
+  /**
+   * Creates an external ID with an email.
+   *
+   * @param scheme the scheme name, must not contain colons (':')
+   * @param id the external ID, must not contain colons (':')
+   * @param accountId the ID of the account to which the external ID belongs
+   * @param email the email of the external ID, may be {@code null}
+   * @return the created external ID
+   */
   public static ExternalId createWithEmail(
       String scheme, String id, Account.Id accountId, @Nullable String email) {
     return createWithEmail(Key.create(scheme, id), accountId, email);
diff --git a/java/com/google/gerrit/server/api/GerritApiImpl.java b/java/com/google/gerrit/server/api/GerritApiImpl.java
index 24fad34..6a6415e 100644
--- a/java/com/google/gerrit/server/api/GerritApiImpl.java
+++ b/java/com/google/gerrit/server/api/GerritApiImpl.java
@@ -25,7 +25,7 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class GerritApiImpl implements GerritApi {
+class GerritApiImpl implements GerritApi {
   private final Accounts accounts;
   private final Changes changes;
   private final Config config;
diff --git a/java/com/google/gerrit/server/auth/ldap/Helper.java b/java/com/google/gerrit/server/auth/ldap/Helper.java
index 5af730f..16c1724 100644
--- a/java/com/google/gerrit/server/auth/ldap/Helper.java
+++ b/java/com/google/gerrit/server/auth/ldap/Helper.java
@@ -196,7 +196,7 @@
       Throwables.throwIfInstanceOf(e.getException(), IOException.class);
       Throwables.throwIfInstanceOf(e.getException(), NamingException.class);
       Throwables.throwIfInstanceOf(e.getException(), RuntimeException.class);
-      LdapRealm.log.warn("Internal error", e.getException());
+      log.warn("Internal error", e.getException());
       return null;
     } finally {
       ctx.logout();
@@ -343,7 +343,7 @@
             }
           }
         } catch (NamingException e) {
-          LdapRealm.log.warn("Could not find group " + groupDN, e);
+          log.warn("Could not find group {}", groupDN, e);
         }
         cachedParentsDNs = dns.build();
         parentGroups.put(groupDN, cachedParentsDNs);
@@ -474,10 +474,10 @@
       try {
         return LdapType.guessType(ctx);
       } catch (NamingException e) {
-        LdapRealm.log.warn(
-            "Cannot discover type of LDAP server at "
-                + server
-                + ", assuming the server is RFC 2307 compliant.",
+        log.warn(
+            "Cannot discover type of LDAP server at {},"
+                + " assuming the server is RFC 2307 compliant.",
+            server,
             e);
         return LdapType.RFC_2307;
       }
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index 6184674..b83c7b2 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -61,7 +61,8 @@
 
 @Singleton
 class LdapRealm extends AbstractRealm {
-  static final Logger log = LoggerFactory.getLogger(LdapRealm.class);
+  private static final Logger log = LoggerFactory.getLogger(LdapRealm.class);
+
   static final String LDAP = "com.sun.jndi.ldap.LdapCtxFactory";
   static final String USERNAME = "username";
 
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
index 1ac3bca..f380051 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
@@ -16,16 +16,23 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.CacheSerializer;
+import com.google.gerrit.server.cache.IntKeyCacheSerializer;
+import com.google.gerrit.server.cache.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.proto.Cache.OAuthTokenProto;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
+import java.io.IOException;
 
 @Singleton
 public class OAuthTokenCache {
@@ -37,11 +44,47 @@
     return new CacheModule() {
       @Override
       protected void configure() {
-        persist(OAUTH_TOKENS, Account.Id.class, OAuthToken.class);
+        persist(OAUTH_TOKENS, Account.Id.class, OAuthToken.class)
+            .version(1)
+            .keySerializer(new IntKeyCacheSerializer<>(Account.Id::new))
+            .valueSerializer(new Serializer());
       }
     };
   }
 
+  // Defined outside of OAuthToken class, since that is in the extensions package which doesn't have
+  // access to the serializer code.
+  @VisibleForTesting
+  static class Serializer implements CacheSerializer<OAuthToken> {
+    @Override
+    public byte[] serialize(OAuthToken object) {
+      return ProtoCacheSerializers.toByteArray(
+          OAuthTokenProto.newBuilder()
+              .setToken(object.getToken())
+              .setSecret(object.getSecret())
+              .setRaw(object.getRaw())
+              .setExpiresAt(object.getExpiresAt())
+              .setProviderId(Strings.nullToEmpty(object.getProviderId()))
+              .build());
+    }
+
+    @Override
+    public OAuthToken deserialize(byte[] in) {
+      OAuthTokenProto proto;
+      try {
+        proto = OAuthTokenProto.parseFrom(in);
+      } catch (IOException e) {
+        throw new IllegalArgumentException("failed to deserialize OAuthToken");
+      }
+      return new OAuthToken(
+          proto.getToken(),
+          proto.getSecret(),
+          proto.getRaw(),
+          proto.getExpiresAt(),
+          Strings.emptyToNull(proto.getProviderId()));
+    }
+  }
+
   private final Cache<Account.Id, OAuthToken> cache;
 
   @Inject
diff --git a/java/com/google/gerrit/server/cache/BooleanCacheSerializer.java b/java/com/google/gerrit/server/cache/BooleanCacheSerializer.java
new file mode 100644
index 0000000..59fc946
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/BooleanCacheSerializer.java
@@ -0,0 +1,44 @@
+// 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.cache;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.protobuf.TextFormat;
+import java.util.Arrays;
+
+public enum BooleanCacheSerializer implements CacheSerializer<Boolean> {
+  INSTANCE;
+
+  private static final byte[] TRUE = Boolean.toString(true).getBytes(UTF_8);
+  private static final byte[] FALSE = Boolean.toString(false).getBytes(UTF_8);
+
+  @Override
+  public byte[] serialize(Boolean object) {
+    byte[] bytes = checkNotNull(object) ? TRUE : FALSE;
+    return Arrays.copyOf(bytes, bytes.length);
+  }
+
+  @Override
+  public Boolean deserialize(byte[] in) {
+    if (Arrays.equals(in, TRUE)) {
+      return Boolean.TRUE;
+    } else if (Arrays.equals(in, FALSE)) {
+      return Boolean.FALSE;
+    }
+    throw new IllegalArgumentException("Invalid Boolean value: " + TextFormat.escapeBytes(in));
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/CacheSerializer.java b/java/com/google/gerrit/server/cache/CacheSerializer.java
index 6bd1322..08deecd 100644
--- a/java/com/google/gerrit/server/cache/CacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/CacheSerializer.java
@@ -14,13 +14,29 @@
 
 package com.google.gerrit.server.cache;
 
-import java.io.IOException;
-
-/** Interface for serializing/deserializing a type to/from a persistent cache. */
+/**
+ * Interface for serializing/deserializing a type to/from a persistent cache.
+ *
+ * <p>Implementations are null-hostile and will throw exceptions from {@link #serialize} when passed
+ * null values, unless otherwise specified.
+ */
 public interface CacheSerializer<T> {
-  /** Serializes the object to a new byte array. */
-  byte[] serialize(T object) throws IOException;
+  /**
+   * Serializes the object to a new byte array.
+   *
+   * @param object object to serialize.
+   * @return serialized byte array representation.
+   * @throws RuntimeException for malformed input, for example null or an otherwise unsupported
+   *     value.
+   */
+  byte[] serialize(T object);
 
-  /** Deserializes a single object from the given byte array. */
-  T deserialize(byte[] in) throws IOException;
+  /**
+   * Deserializes a single object from the given byte array.
+   *
+   * @param in serialized byte array representation.
+   * @throws RuntimeException for malformed input, for example null or an otherwise corrupt
+   *     serialized representation.
+   */
+  T deserialize(byte[] in);
 }
diff --git a/java/com/google/gerrit/server/cache/EnumCacheSerializer.java b/java/com/google/gerrit/server/cache/EnumCacheSerializer.java
index 6ea6121..c5be783 100644
--- a/java/com/google/gerrit/server/cache/EnumCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/EnumCacheSerializer.java
@@ -14,28 +14,26 @@
 
 package com.google.gerrit.server.cache;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.base.Converter;
 import com.google.common.base.Enums;
-import java.io.IOException;
 
 public class EnumCacheSerializer<E extends Enum<E>> implements CacheSerializer<E> {
-  private final Class<E> clazz;
+  private final Converter<String, E> converter;
 
   public EnumCacheSerializer(Class<E> clazz) {
-    this.clazz = clazz;
+    this.converter = Enums.stringConverter(clazz);
   }
 
   @Override
-  public byte[] serialize(E object) throws IOException {
-    return object.name().getBytes(UTF_8);
+  public byte[] serialize(E object) {
+    return converter.reverse().convert(checkNotNull(object)).getBytes(UTF_8);
   }
 
   @Override
-  public E deserialize(byte[] in) throws IOException {
-    String name = new String(in, UTF_8);
-    return Enums.getIfPresent(clazz, name)
-        .toJavaUtil()
-        .orElseThrow(() -> new IOException("Invalid " + clazz.getName() + " value: " + name));
+  public E deserialize(byte[] in) {
+    return converter.convert(new String(checkNotNull(in), UTF_8));
   }
 }
diff --git a/java/com/google/gerrit/server/cache/IntKeyCacheSerializer.java b/java/com/google/gerrit/server/cache/IntKeyCacheSerializer.java
new file mode 100644
index 0000000..a07c004
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/IntKeyCacheSerializer.java
@@ -0,0 +1,38 @@
+// 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.cache;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gwtorm.client.IntKey;
+import java.util.function.Function;
+
+public class IntKeyCacheSerializer<K extends IntKey<?>> implements CacheSerializer<K> {
+  private final Function<Integer, K> factory;
+
+  public IntKeyCacheSerializer(Function<Integer, K> factory) {
+    this.factory = checkNotNull(factory);
+  }
+
+  @Override
+  public byte[] serialize(K object) {
+    return IntegerCacheSerializer.INSTANCE.serialize(object.get());
+  }
+
+  @Override
+  public K deserialize(byte[] in) {
+    return factory.apply(IntegerCacheSerializer.INSTANCE.deserialize(in));
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/IntegerCacheSerializer.java b/java/com/google/gerrit/server/cache/IntegerCacheSerializer.java
new file mode 100644
index 0000000..5eddb71
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/IntegerCacheSerializer.java
@@ -0,0 +1,63 @@
+// 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.cache;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.protobuf.CodedInputStream;
+import com.google.protobuf.CodedOutputStream;
+import com.google.protobuf.TextFormat;
+import java.io.IOException;
+import java.util.Arrays;
+
+public enum IntegerCacheSerializer implements CacheSerializer<Integer> {
+  INSTANCE;
+
+  // Same as com.google.protobuf.WireFormat#MAX_VARINT_SIZE. Note that negative values take up more
+  // than MAX_VARINT32_SIZE space.
+  private static final int MAX_VARINT_SIZE = 10;
+
+  @Override
+  public byte[] serialize(Integer object) {
+    byte[] buf = new byte[MAX_VARINT_SIZE];
+    CodedOutputStream cout = CodedOutputStream.newInstance(buf);
+    try {
+      cout.writeInt32NoTag(checkNotNull(object));
+      cout.flush();
+    } catch (IOException e) {
+      throw new IllegalStateException("Failed to serialize int");
+    }
+    int n = cout.getTotalBytesWritten();
+    return n == buf.length ? buf : Arrays.copyOfRange(buf, 0, n);
+  }
+
+  @Override
+  public Integer deserialize(byte[] in) {
+    CodedInputStream cin = CodedInputStream.newInstance(checkNotNull(in));
+    int ret;
+    try {
+      ret = cin.readRawVarint32();
+    } catch (IOException e) {
+      throw new IllegalArgumentException("Failed to deserialize int");
+    }
+    int n = cin.getTotalBytesRead();
+    if (n != in.length) {
+      throw new IllegalArgumentException(
+          "Extra bytes in int representation: "
+              + TextFormat.escapeBytes(Arrays.copyOfRange(in, n, in.length)));
+    }
+    return ret;
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/JavaCacheSerializer.java b/java/com/google/gerrit/server/cache/JavaCacheSerializer.java
index 750c5df..55358bc 100644
--- a/java/com/google/gerrit/server/cache/JavaCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/JavaCacheSerializer.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.cache;
 
+import com.google.gerrit.common.Nullable;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -23,29 +24,33 @@
 /**
  * Serializer that uses default Java serialization.
  *
+ * <p>Unlike most {@link CacheSerializer} implementations, serializing null is supported.
+ *
  * @param <T> type to serialize. Must implement {@code Serializable}, but due to implementation
  *     details this is only checked at runtime.
  */
 public class JavaCacheSerializer<T> implements CacheSerializer<T> {
   @Override
-  public byte[] serialize(T object) throws IOException {
+  public byte[] serialize(@Nullable T object) {
     try (ByteArrayOutputStream bout = new ByteArrayOutputStream();
         ObjectOutputStream oout = new ObjectOutputStream(bout)) {
       oout.writeObject(object);
       oout.flush();
       return bout.toByteArray();
+    } catch (IOException e) {
+      throw new IllegalArgumentException("Failed to serialize object", e);
     }
   }
 
   @SuppressWarnings("unchecked")
   @Override
-  public T deserialize(byte[] in) throws IOException {
+  public T deserialize(byte[] in) {
     Object object;
     try (ByteArrayInputStream bin = new ByteArrayInputStream(in);
         ObjectInputStream oin = new ObjectInputStream(bin)) {
       object = oin.readObject();
-    } catch (ClassNotFoundException e) {
-      throw new IOException("Failed to deserialize object of type", e);
+    } catch (ClassNotFoundException | IOException e) {
+      throw new IllegalArgumentException("Failed to deserialize object", e);
     }
     return (T) object;
   }
diff --git a/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java b/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
index b1a65fe..44e2bb2 100644
--- a/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
@@ -52,11 +52,7 @@
 
       @Override
       public void funnel(K from, PrimitiveSink into) {
-        try {
-          Funnels.byteArrayFunnel().funnel(serializer.serialize(from), into);
-        } catch (IOException e) {
-          throw new RuntimeException("Cannot hash", e);
-        }
+        Funnels.byteArrayFunnel().funnel(serializer.serialize(from), into);
       }
     };
   }
diff --git a/java/com/google/gerrit/server/cache/testing/BUILD b/java/com/google/gerrit/server/cache/testing/BUILD
new file mode 100644
index 0000000..ed412af
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/testing/BUILD
@@ -0,0 +1,13 @@
+package(default_testonly = 1)
+
+java_library(
+    name = "testing",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//lib:guava",
+        "//lib:protobuf",
+        "//lib/commons:lang3",
+        "//lib/truth",
+    ],
+)
diff --git a/java/com/google/gerrit/server/cache/testing/CacheSerializerTestUtil.java b/java/com/google/gerrit/server/cache/testing/CacheSerializerTestUtil.java
new file mode 100644
index 0000000..5d41490
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/testing/CacheSerializerTestUtil.java
@@ -0,0 +1,30 @@
+// 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.cache.testing;
+
+import com.google.protobuf.ByteString;
+
+/** Static utilities for testing cache serializers. */
+public class CacheSerializerTestUtil {
+  public static ByteString bytes(int... ints) {
+    byte[] bytes = new byte[ints.length];
+    for (int i = 0; i < ints.length; i++) {
+      bytes[i] = (byte) ints[i];
+    }
+    return ByteString.copyFrom(bytes);
+  }
+
+  private CacheSerializerTestUtil() {}
+}
diff --git a/java/com/google/gerrit/server/cache/testing/SerializedClassSubject.java b/java/com/google/gerrit/server/cache/testing/SerializedClassSubject.java
new file mode 100644
index 0000000..78900cb
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/testing/SerializedClassSubject.java
@@ -0,0 +1,81 @@
+// 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.cache.testing;
+
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.Type;
+import java.util.Map;
+import org.apache.commons.lang3.reflect.FieldUtils;
+
+/**
+ * Subject about classes that are serialized into persistent caches.
+ *
+ * <p>Hand-written {@link com.google.gerrit.server.cache.CacheSerializer CacheSerializer}
+ * implementations depend on the exact representation of the data stored in a class, so it is
+ * important to verify any assumptions about the structure of the serialized classes. This class
+ * contains assertions about serialized classes, and should be used for every class that has a
+ * custom serializer implementation.
+ *
+ * <p>Changing fields of a serialized class (or abstract methods, in the case of {@code @AutoValue}
+ * classes) will likely require changes to the serializer implementation, and may require bumping
+ * the {@link com.google.gerrit.server.cache.PersistentCacheBinding#version(int) version} in the
+ * cache binding, in case the representation has changed in such a way that old serialized data
+ * becomes unreadable.
+ *
+ * <p>Changes to a serialized class such as adding or removing fields generally requires a change to
+ * the hand-written serializer. Usually, serializer implementations should be written in such a way
+ * that new fields are considered optional, and won't require bumping the version.
+ */
+public class SerializedClassSubject extends Subject<SerializedClassSubject, Class<?>> {
+  public static SerializedClassSubject assertThatSerializedClass(Class<?> actual) {
+    // This formulation fails in Eclipse 4.7.3a with "The type
+    // SerializedClassSubject does not define SerializedClassSubject() that is
+    // applicable here", due to
+    // https://bugs.eclipse.org/bugs/show_bug.cgi?id=534694 or a similar bug:
+    // return assertAbout(SerializedClassSubject::new).that(actual);
+    Subject.Factory<SerializedClassSubject, Class<?>> factory =
+        (m, a) -> new SerializedClassSubject(m, a);
+    return assertAbout(factory).that(actual);
+  }
+
+  private SerializedClassSubject(FailureMetadata metadata, Class<?> actual) {
+    super(metadata, actual);
+  }
+
+  public void isConcrete() {
+    isNotNull();
+    assertWithMessage("expected class %s to be concrete", actual().getName())
+        .that(!Modifier.isAbstract(actual().getModifiers()))
+        .isTrue();
+  }
+
+  public void hasFields(Map<String, Type> expectedFields) {
+    isConcrete();
+    assertThat(
+            FieldUtils.getAllFieldsList(actual())
+                .stream()
+                .filter(f -> !Modifier.isStatic(f.getModifiers()))
+                .collect(toImmutableMap(Field::getName, Field::getGenericType)))
+        .containsExactlyEntriesIn(expectedFields);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index 6449662..a4eb90f 100644
--- a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -42,6 +42,7 @@
 import com.google.inject.Module;
 import com.google.inject.name.Named;
 import com.google.protobuf.ByteString;
+import com.google.protobuf.InvalidProtocolBufferException;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.Collection;
@@ -176,7 +177,7 @@
     @VisibleForTesting
     static class Serializer implements CacheSerializer<Key> {
       @Override
-      public byte[] serialize(Key object) throws IOException {
+      public byte[] serialize(Key object) {
         byte[] buf = new byte[Constants.OBJECT_ID_LENGTH];
         ChangeKindKeyProto.Builder b = ChangeKindKeyProto.newBuilder();
         object.getPrior().copyRawTo(buf, 0);
@@ -188,12 +189,16 @@
       }
 
       @Override
-      public Key deserialize(byte[] in) throws IOException {
-        ChangeKindKeyProto proto = ChangeKindKeyProto.parseFrom(in);
-        return new Key(
-            ObjectId.fromRaw(proto.getPrior().toByteArray()),
-            ObjectId.fromRaw(proto.getNext().toByteArray()),
-            proto.getStrategyName());
+      public Key deserialize(byte[] in) {
+        try {
+          ChangeKindKeyProto proto = ChangeKindKeyProto.parseFrom(in);
+          return new Key(
+              ObjectId.fromRaw(proto.getPrior().toByteArray()),
+              ObjectId.fromRaw(proto.getNext().toByteArray()),
+              proto.getStrategyName());
+        } catch (InvalidProtocolBufferException e) {
+          throw new IllegalArgumentException("Failed to deserialize object", e);
+        }
       }
     }
   }
diff --git a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
index ea91e0f..a192228 100644
--- a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
+++ b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
@@ -16,20 +16,20 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.ioutil.BasicSerialization.readString;
-import static com.google.gerrit.server.ioutil.BasicSerialization.writeString;
-import static org.eclipse.jgit.lib.ObjectIdSerializer.readWithoutMarker;
-import static org.eclipse.jgit.lib.ObjectIdSerializer.writeWithoutMarker;
 
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 import com.google.common.base.MoreObjects;
 import com.google.common.cache.Cache;
 import com.google.common.cache.Weigher;
-import com.google.common.collect.ImmutableBiMap;
 import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.cache.BooleanCacheSerializer;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.CacheSerializer;
+import com.google.gerrit.server.cache.ProtoCacheSerializers;
+import com.google.gerrit.server.cache.proto.Cache.MergeabilityKeyProto;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.submit.SubmitDryRun;
@@ -37,14 +37,13 @@
 import com.google.inject.Module;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
+import com.google.protobuf.ByteString;
 import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
-import java.io.Serializable;
 import java.util.Arrays;
 import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -58,30 +57,16 @@
 
   private static final String CACHE_NAME = "mergeability";
 
-  public static final ImmutableBiMap<SubmitType, Character> SUBMIT_TYPES =
-      new ImmutableBiMap.Builder<SubmitType, Character>()
-          .put(SubmitType.INHERIT, 'I')
-          .put(SubmitType.FAST_FORWARD_ONLY, 'F')
-          .put(SubmitType.MERGE_IF_NECESSARY, 'M')
-          .put(SubmitType.REBASE_ALWAYS, 'P')
-          .put(SubmitType.REBASE_IF_NECESSARY, 'R')
-          .put(SubmitType.MERGE_ALWAYS, 'A')
-          .put(SubmitType.CHERRY_PICK, 'C')
-          .build();
-
-  static {
-    checkState(
-        SUBMIT_TYPES.size() == SubmitType.values().length,
-        "SubmitType <-> char BiMap needs updating");
-  }
-
   public static Module module() {
     return new CacheModule() {
       @Override
       protected void configure() {
         persist(CACHE_NAME, EntryKey.class, Boolean.class)
             .maximumWeight(1 << 20)
-            .weigher(MergeabilityWeigher.class);
+            .weigher(MergeabilityWeigher.class)
+            .version(1)
+            .keySerializer(EntryKey.Serializer.INSTANCE)
+            .valueSerializer(BooleanCacheSerializer.INSTANCE);
         bind(MergeabilityCache.class).to(MergeabilityCacheImpl.class);
       }
     };
@@ -91,9 +76,7 @@
     return ref != null && ref.getObjectId() != null ? ref.getObjectId() : ObjectId.zeroId();
   }
 
-  public static class EntryKey implements Serializable {
-    private static final long serialVersionUID = 1L;
-
+  public static class EntryKey {
     private ObjectId commit;
     private ObjectId into;
     private SubmitType submitType;
@@ -154,26 +137,44 @@
           .toString();
     }
 
-    private void writeObject(ObjectOutputStream out) throws IOException {
-      writeWithoutMarker(out, commit);
-      writeWithoutMarker(out, into);
-      Character c = SUBMIT_TYPES.get(submitType);
-      if (c == null) {
-        throw new IOException("Invalid submit type: " + submitType);
-      }
-      out.writeChar(c);
-      writeString(out, mergeStrategy);
-    }
+    static enum Serializer implements CacheSerializer<EntryKey> {
+      INSTANCE;
 
-    private void readObject(ObjectInputStream in) throws IOException {
-      commit = readWithoutMarker(in);
-      into = readWithoutMarker(in);
-      char t = in.readChar();
-      submitType = SUBMIT_TYPES.inverse().get(t);
-      if (submitType == null) {
-        throw new IOException("Invalid submit type code: " + t);
+      private static final Converter<String, SubmitType> SUBMIT_TYPE_CONVERTER =
+          Enums.stringConverter(SubmitType.class);
+
+      @Override
+      public byte[] serialize(EntryKey object) {
+        byte[] buf = new byte[Constants.OBJECT_ID_LENGTH];
+        MergeabilityKeyProto.Builder b = MergeabilityKeyProto.newBuilder();
+        object.getCommit().copyRawTo(buf, 0);
+        b.setCommit(ByteString.copyFrom(buf));
+        object.getInto().copyRawTo(buf, 0);
+        b.setInto(ByteString.copyFrom(buf));
+        b.setSubmitType(SUBMIT_TYPE_CONVERTER.reverse().convert(object.getSubmitType()));
+        b.setMergeStrategy(object.getMergeStrategy());
+        return ProtoCacheSerializers.toByteArray(b.build());
       }
-      mergeStrategy = readString(in);
+
+      @Override
+      public EntryKey deserialize(byte[] in) {
+        MergeabilityKeyProto proto;
+        try {
+          proto = MergeabilityKeyProto.parseFrom(in);
+        } catch (IOException e) {
+          throw new IllegalArgumentException("Failed to deserialize mergeability cache key");
+        }
+        byte[] buf = new byte[Constants.OBJECT_ID_LENGTH];
+        proto.getCommit().copyTo(buf, 0);
+        ObjectId commit = ObjectId.fromRaw(buf);
+        proto.getInto().copyTo(buf, 0);
+        ObjectId into = ObjectId.fromRaw(buf);
+        return new EntryKey(
+            commit,
+            into,
+            SUBMIT_TYPE_CONVERTER.convert(proto.getSubmitType()),
+            proto.getMergeStrategy());
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 09687f5..cb0cdf9 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -170,7 +170,6 @@
 import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
-import com.google.gerrit.server.restapi.config.ConfigRestModule;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
 import com.google.gerrit.server.rules.PrologModule;
@@ -306,12 +305,6 @@
 
     install(new AuditModule());
     bind(UiActions.class);
-    install(new com.google.gerrit.server.restapi.access.Module());
-    install(new ConfigRestModule());
-    install(new com.google.gerrit.server.restapi.change.Module());
-    install(new com.google.gerrit.server.restapi.account.Module());
-    install(new com.google.gerrit.server.restapi.project.Module());
-    install(new com.google.gerrit.server.restapi.group.Module());
 
     bind(GitReferenceUpdated.class);
     DynamicMap.mapOf(binder(), new TypeLiteral<Cache<?, ?>>() {});
diff --git a/java/com/google/gerrit/server/git/ValidationError.java b/java/com/google/gerrit/server/git/ValidationError.java
index 2fd65d2..28d5171 100644
--- a/java/com/google/gerrit/server/git/ValidationError.java
+++ b/java/com/google/gerrit/server/git/ValidationError.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.git;
 
 import java.util.Objects;
-import org.slf4j.Logger;
 
 /** Indicates a problem with Git based data. */
 public class ValidationError {
@@ -46,10 +45,6 @@
     void error(ValidationError error);
   }
 
-  public static Sink createLoggerSink(String message, Logger log) {
-    return error -> log.error(message + error.getMessage());
-  }
-
   @Override
   public boolean equals(Object o) {
     if (o == this) {
diff --git a/java/com/google/gerrit/server/git/meta/TabFile.java b/java/com/google/gerrit/server/git/meta/TabFile.java
index 68950602..ef25cd8 100644
--- a/java/com/google/gerrit/server/git/meta/TabFile.java
+++ b/java/com/google/gerrit/server/git/meta/TabFile.java
@@ -24,7 +24,6 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import org.slf4j.Logger;
 
 public class TabFile {
   @FunctionalInterface
@@ -141,8 +140,4 @@
     }
     return r.toString();
   }
-
-  public static ValidationError.Sink createLoggerSink(String file, Logger log) {
-    return ValidationError.createLoggerSink("Error parsing file " + file + ": ", log);
-  }
 }
diff --git a/java/com/google/gerrit/server/group/testing/BUILD b/java/com/google/gerrit/server/group/testing/BUILD
index 134de78..8b8cd00 100644
--- a/java/com/google/gerrit/server/group/testing/BUILD
+++ b/java/com/google/gerrit/server/group/testing/BUILD
@@ -8,7 +8,8 @@
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
-        "//lib:truth",
+        "//lib:guava",
         "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 68b1ff9..5db347e 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -22,6 +22,9 @@
 import static com.google.gerrit.index.FieldDef.prefix;
 import static com.google.gerrit.index.FieldDef.storedOnly;
 import static com.google.gerrit.index.FieldDef.timestamp;
+import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
+import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.CHANGE_CODEC;
+import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
@@ -64,7 +67,6 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
 import com.google.gson.Gson;
-import com.google.gwtorm.protobuf.CodecFactory;
 import com.google.gwtorm.protobuf.ProtobufCodec;
 import com.google.gwtorm.server.OrmException;
 import com.google.protobuf.CodedOutputStream;
@@ -468,15 +470,10 @@
       exact(ChangeQueryBuilder.FIELD_EXACTCOMMITTER)
           .buildRepeatable(ChangeField::getCommitterNameAndEmail);
 
-  public static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
-
   /** Serialized change object, used for pre-populating results. */
   public static final FieldDef<ChangeData, byte[]> CHANGE =
       storedOnly("_change").build(changeGetter(CHANGE_CODEC::encodeToByteArray));
 
-  public static final ProtobufCodec<PatchSetApproval> APPROVAL_CODEC =
-      CodecFactory.encoder(PatchSetApproval.class);
-
   /** Serialized approvals for the current patch set, used for pre-populating results. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> APPROVAL =
       storedOnly("_approval")
@@ -596,9 +593,6 @@
               cd ->
                   cd.patchSets().stream().flatMap(ps -> ps.getGroups().stream()).collect(toSet()));
 
-  public static final ProtobufCodec<PatchSet> PATCH_SET_CODEC =
-      CodecFactory.encoder(PatchSet.class);
-
   /** Serialized patch set object, used for pre-populating results. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> PATCH_SET =
       storedOnly("_patch_set").buildRepeatable(cd -> toProtos(PATCH_SET_CODEC, cd.patchSets()));
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index f5fefeb..1bbecc8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -564,12 +564,7 @@
 
   /** @return all change messages, in chronological order, oldest first. */
   public ImmutableList<ChangeMessage> getChangeMessages() {
-    return state.allChangeMessages();
-  }
-
-  /** @return change messages by patch set, in chronological order, oldest first. */
-  public ImmutableListMultimap<PatchSet.Id, ChangeMessage> getChangeMessagesByPatchSet() {
-    return state.changeMessagesByPatchSet();
+    return state.changeMessages();
   }
 
   /** @return inline comments on each revision. */
@@ -670,28 +665,6 @@
     return state.readOnlyUntil();
   }
 
-  public boolean isPrivate() {
-    if (state.isPrivate() == null) {
-      return false;
-    }
-    return state.isPrivate();
-  }
-
-  public boolean isWorkInProgress() {
-    if (state.isWorkInProgress() == null) {
-      return false;
-    }
-    return state.isWorkInProgress();
-  }
-
-  public Change.Id getRevertOf() {
-    return state.revertOf();
-  }
-
-  public boolean hasReviewStarted() {
-    return state.hasReviewStarted();
-  }
-
   @Override
   protected void onLoad(LoadHandle handle)
       throws NoSuchChangeException, IOException, ConfigInvalidException {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 676dbb8..5658569 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -128,10 +128,7 @@
           + P
           + list(state.submitRecords(), P + list(2, str(4) + P + K) + P)
           + P
-          + list(state.allChangeMessages(), changeMessage())
-          // Just key overhead for map, already counted messages in previous.
-          + P
-          + map(state.changeMessagesByPatchSet().asMap(), patchSetId())
+          + list(state.changeMessages(), changeMessage())
           + P
           + map(state.publishedComments().asMap(), comment())
           + T // readOnlyUntil
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index d6472bc..2eb30ff 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.notedb;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
@@ -44,7 +45,6 @@
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableTable;
-import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.MultimapBuilder;
@@ -145,7 +145,6 @@
   private final Map<ApprovalKey, PatchSetApproval> approvals;
   private final List<PatchSetApproval> bufferedApprovals;
   private final List<ChangeMessage> allChangeMessages;
-  private final ListMultimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet;
 
   // Non-final private members filled in during the parsing process.
   private String branch;
@@ -193,7 +192,6 @@
     reviewerUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
-    changeMessagesByPatchSet = LinkedListMultimap.create();
     comments = MultimapBuilder.hashKeys().arrayListValues().build();
     patchSets = new HashMap<>();
     deletedPatchSets = new HashSet<>();
@@ -253,7 +251,7 @@
         assignee != null ? assignee.orElse(null) : null,
         status,
         Sets.newLinkedHashSet(Lists.reverse(pastAssignees)),
-        hashtags,
+        firstNonNull(hashtags, ImmutableSet.of()),
         patchSets,
         buildApprovals(),
         ReviewerSet.fromTable(Tables.transpose(reviewers)),
@@ -264,12 +262,11 @@
         buildReviewerUpdates(),
         submitRecords,
         buildAllMessages(),
-        buildMessagesByPatchSet(),
         comments,
         readOnlyUntil,
-        isPrivate,
-        workInProgress,
-        hasReviewStarted,
+        firstNonNull(isPrivate, false),
+        firstNonNull(workInProgress, false),
+        firstNonNull(hasReviewStarted, true),
         revertOf);
   }
 
@@ -318,13 +315,6 @@
     return Lists.reverse(allChangeMessages);
   }
 
-  private ListMultimap<PatchSet.Id, ChangeMessage> buildMessagesByPatchSet() {
-    for (Collection<ChangeMessage> v : changeMessagesByPatchSet.asMap().values()) {
-      Collections.reverse((List<ChangeMessage>) v);
-    }
-    return changeMessagesByPatchSet;
-  }
-
   private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
     Timestamp ts = new Timestamp(commit.getCommitterIdent().getWhen().getTime());
 
@@ -751,7 +741,6 @@
     changeMessage.setMessage(changeMsgString);
     changeMessage.setTag(tag);
     changeMessage.setRealAuthor(realAccountId);
-    changeMessagesByPatchSet.put(psId, changeMessage);
     allChangeMessages.add(changeMessage);
   }
 
@@ -1088,8 +1077,6 @@
     // (or otherwise missing) patch sets. This is safer than trying to prevent
     // insertion, as it will also filter out items racily added after the patch
     // set was deleted.
-    changeMessagesByPatchSet.keys().retainAll(patchSets.keySet());
-
     int pruned =
         pruneEntitiesForMissingPatchSets(allChangeMessages, ChangeMessage::getPatchSetId, missing);
     pruned +=
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 1dd944d..78734f9 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -57,33 +57,15 @@
 @AutoValue
 public abstract class ChangeNotesState {
   static ChangeNotesState empty(Change change) {
-    return new AutoValue_ChangeNotesState(
-        null,
-        change.getId(),
-        null,
-        ImmutableSet.of(),
-        ImmutableSet.of(),
-        ImmutableList.of(),
-        ImmutableList.of(),
-        ReviewerSet.empty(),
-        ReviewerByEmailSet.empty(),
-        ReviewerSet.empty(),
-        ReviewerByEmailSet.empty(),
-        ImmutableList.of(),
-        ImmutableList.of(),
-        ImmutableList.of(),
-        ImmutableList.of(),
-        ImmutableListMultimap.of(),
-        ImmutableListMultimap.of(),
-        null,
-        null,
-        null,
-        true,
-        null);
+    return Builder.empty(change.getId()).build();
+  }
+
+  static Builder builder() {
+    return new AutoValue_ChangeNotesState.Builder();
   }
 
   static ChangeNotesState create(
-      @Nullable ObjectId metaId,
+      ObjectId metaId,
       Change.Id changeId,
       Change.Key changeKey,
       Timestamp createdOn,
@@ -97,8 +79,8 @@
       @Nullable String submissionId,
       @Nullable Account.Id assignee,
       @Nullable Change.Status status,
-      @Nullable Set<Account.Id> pastAssignees,
-      @Nullable Set<String> hashtags,
+      Set<Account.Id> pastAssignees,
+      Set<String> hashtags,
       Map<PatchSet.Id, PatchSet> patchSets,
       ListMultimap<PatchSet.Id, PatchSetApproval> approvals,
       ReviewerSet reviewers,
@@ -108,56 +90,55 @@
       List<Account.Id> allPastReviewers,
       List<ReviewerStatusUpdate> reviewerUpdates,
       List<SubmitRecord> submitRecords,
-      List<ChangeMessage> allChangeMessages,
-      ListMultimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet,
+      List<ChangeMessage> changeMessages,
       ListMultimap<RevId, Comment> publishedComments,
       @Nullable Timestamp readOnlyUntil,
-      @Nullable Boolean isPrivate,
-      @Nullable Boolean workInProgress,
+      boolean isPrivate,
+      boolean workInProgress,
       boolean hasReviewStarted,
       @Nullable Change.Id revertOf) {
-    if (hashtags == null) {
-      hashtags = ImmutableSet.of();
-    }
-    return new AutoValue_ChangeNotesState(
+    checkNotNull(
         metaId,
-        changeId,
-        new AutoValue_ChangeNotesState_ChangeColumns(
-            changeKey,
-            createdOn,
-            lastUpdatedOn,
-            owner,
-            branch,
-            currentPatchSetId,
-            subject,
-            topic,
-            originalSubject,
-            submissionId,
-            assignee,
-            status,
-            isPrivate,
-            workInProgress,
-            hasReviewStarted,
-            revertOf),
-        ImmutableSet.copyOf(pastAssignees),
-        ImmutableSet.copyOf(hashtags),
-        ImmutableList.copyOf(patchSets.entrySet()),
-        ImmutableList.copyOf(approvals.entries()),
-        reviewers,
-        reviewersByEmail,
-        pendingReviewers,
-        pendingReviewersByEmail,
-        ImmutableList.copyOf(allPastReviewers),
-        ImmutableList.copyOf(reviewerUpdates),
-        ImmutableList.copyOf(submitRecords),
-        ImmutableList.copyOf(allChangeMessages),
-        ImmutableListMultimap.copyOf(changeMessagesByPatchSet),
-        ImmutableListMultimap.copyOf(publishedComments),
-        readOnlyUntil,
-        isPrivate,
-        workInProgress,
-        hasReviewStarted,
-        revertOf);
+        "metaId is required when passing arguments to create(...). To create an empty %s without"
+            + " NoteDb data, use empty(...) instead",
+        ChangeNotesState.class.getSimpleName());
+    return builder()
+        .metaId(metaId)
+        .changeId(changeId)
+        .columns(
+            new AutoValue_ChangeNotesState_ChangeColumns.Builder()
+                .changeKey(changeKey)
+                .createdOn(createdOn)
+                .lastUpdatedOn(lastUpdatedOn)
+                .owner(owner)
+                .branch(branch)
+                .currentPatchSetId(currentPatchSetId)
+                .subject(subject)
+                .topic(topic)
+                .originalSubject(originalSubject)
+                .submissionId(submissionId)
+                .assignee(assignee)
+                .status(status)
+                .isPrivate(isPrivate)
+                .isWorkInProgress(workInProgress)
+                .hasReviewStarted(hasReviewStarted)
+                .revertOf(revertOf)
+                .build())
+        .pastAssignees(pastAssignees)
+        .hashtags(hashtags)
+        .patchSets(patchSets.entrySet())
+        .approvals(approvals.entries())
+        .reviewers(reviewers)
+        .reviewersByEmail(reviewersByEmail)
+        .pendingReviewers(pendingReviewers)
+        .pendingReviewersByEmail(pendingReviewersByEmail)
+        .allPastReviewers(allPastReviewers)
+        .reviewerUpdates(reviewerUpdates)
+        .submitRecords(submitRecords)
+        .changeMessages(changeMessages)
+        .publishedComments(publishedComments)
+        .readOnlyUntil(readOnlyUntil)
+        .build();
   }
 
   /**
@@ -201,17 +182,51 @@
     @Nullable
     abstract Change.Status status();
 
-    @Nullable
-    abstract Boolean isPrivate();
+    abstract boolean isPrivate();
 
-    @Nullable
-    abstract Boolean isWorkInProgress();
+    abstract boolean isWorkInProgress();
 
-    @Nullable
-    abstract Boolean hasReviewStarted();
+    abstract boolean hasReviewStarted();
 
     @Nullable
     abstract Change.Id revertOf();
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder changeKey(Change.Key changeKey);
+
+      abstract Builder createdOn(Timestamp createdOn);
+
+      abstract Builder lastUpdatedOn(Timestamp lastUpdatedOn);
+
+      abstract Builder owner(Account.Id owner);
+
+      abstract Builder branch(String branch);
+
+      abstract Builder currentPatchSetId(@Nullable PatchSet.Id currentPatchSetId);
+
+      abstract Builder subject(String subject);
+
+      abstract Builder topic(@Nullable String topic);
+
+      abstract Builder originalSubject(@Nullable String originalSubject);
+
+      abstract Builder submissionId(@Nullable String submissionId);
+
+      abstract Builder assignee(@Nullable Account.Id assignee);
+
+      abstract Builder status(@Nullable Change.Status status);
+
+      abstract Builder isPrivate(boolean isPrivate);
+
+      abstract Builder isWorkInProgress(boolean isWorkInProgress);
+
+      abstract Builder hasReviewStarted(boolean hasReviewStarted);
+
+      abstract Builder revertOf(@Nullable Change.Id revertOf);
+
+      abstract ChangeColumns build();
+    }
   }
 
   // Only null if NoteDb is disabled.
@@ -247,27 +262,13 @@
 
   abstract ImmutableList<SubmitRecord> submitRecords();
 
-  abstract ImmutableList<ChangeMessage> allChangeMessages();
-
-  abstract ImmutableListMultimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet();
+  abstract ImmutableList<ChangeMessage> changeMessages();
 
   abstract ImmutableListMultimap<RevId, Comment> publishedComments();
 
   @Nullable
   abstract Timestamp readOnlyUntil();
 
-  @Nullable
-  abstract Boolean isPrivate();
-
-  @Nullable
-  abstract Boolean isWorkInProgress();
-
-  @Nullable
-  abstract Boolean hasReviewStarted();
-
-  @Nullable
-  abstract Change.Id revertOf();
-
   Change newChange(Project.NameKey project) {
     ChangeColumns c = checkNotNull(columns(), "columns are required");
     Change change =
@@ -325,9 +326,9 @@
     change.setLastUpdatedOn(c.lastUpdatedOn());
     change.setSubmissionId(c.submissionId());
     change.setAssignee(c.assignee());
-    change.setPrivate(c.isPrivate() == null ? false : c.isPrivate());
-    change.setWorkInProgress(c.isWorkInProgress() == null ? false : c.isWorkInProgress());
-    change.setReviewStarted(c.hasReviewStarted() == null ? false : c.hasReviewStarted());
+    change.setPrivate(c.isPrivate());
+    change.setWorkInProgress(c.isWorkInProgress());
+    change.setReviewStarted(c.hasReviewStarted());
     change.setRevertOf(c.revertOf());
 
     if (!patchSets().isEmpty()) {
@@ -338,4 +339,61 @@
       change.clearCurrentPatchSet();
     }
   }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+    static Builder empty(Change.Id changeId) {
+      return new AutoValue_ChangeNotesState.Builder()
+          .changeId(changeId)
+          .pastAssignees(ImmutableSet.of())
+          .hashtags(ImmutableSet.of())
+          .patchSets(ImmutableList.of())
+          .approvals(ImmutableList.of())
+          .reviewers(ReviewerSet.empty())
+          .reviewersByEmail(ReviewerByEmailSet.empty())
+          .pendingReviewers(ReviewerSet.empty())
+          .pendingReviewersByEmail(ReviewerByEmailSet.empty())
+          .allPastReviewers(ImmutableList.of())
+          .reviewerUpdates(ImmutableList.of())
+          .submitRecords(ImmutableList.of())
+          .changeMessages(ImmutableList.of())
+          .publishedComments(ImmutableListMultimap.of());
+    }
+
+    abstract Builder metaId(ObjectId metaId);
+
+    abstract Builder changeId(Change.Id changeId);
+
+    abstract Builder columns(ChangeColumns columns);
+
+    abstract Builder pastAssignees(Set<Account.Id> pastAssignees);
+
+    abstract Builder hashtags(Set<String> hashtags);
+
+    abstract Builder patchSets(Iterable<Map.Entry<PatchSet.Id, PatchSet>> patchSets);
+
+    abstract Builder approvals(Iterable<Map.Entry<PatchSet.Id, PatchSetApproval>> approvals);
+
+    abstract Builder reviewers(ReviewerSet reviewers);
+
+    abstract Builder reviewersByEmail(ReviewerByEmailSet reviewersByEmail);
+
+    abstract Builder pendingReviewers(ReviewerSet pendingReviewers);
+
+    abstract Builder pendingReviewersByEmail(ReviewerByEmailSet pendingReviewersByEmail);
+
+    abstract Builder allPastReviewers(List<Account.Id> allPastReviewers);
+
+    abstract Builder reviewerUpdates(List<ReviewerStatusUpdate> reviewerUpdates);
+
+    abstract Builder submitRecords(List<SubmitRecord> submitRecords);
+
+    abstract Builder changeMessages(List<ChangeMessage> changeMessages);
+
+    abstract Builder publishedComments(ListMultimap<RevId, Comment> publishedComments);
+
+    abstract Builder readOnlyUntil(@Nullable Timestamp readOnlyUntil);
+
+    abstract ChangeNotesState build();
+  }
 }
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index b13d921..3a17965 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -47,11 +48,16 @@
   static class Factory {
     private final ChangeData.Factory changeDataFactory;
     private final ChangeNotes.Factory notesFactory;
+    private final IdentifiedUser.GenericFactory identifiedUserFactory;
 
     @Inject
-    Factory(ChangeData.Factory changeDataFactory, ChangeNotes.Factory notesFactory) {
+    Factory(
+        ChangeData.Factory changeDataFactory,
+        ChangeNotes.Factory notesFactory,
+        IdentifiedUser.GenericFactory identifiedUserFactory) {
       this.changeDataFactory = changeDataFactory;
       this.notesFactory = notesFactory;
+      this.identifiedUserFactory = identifiedUserFactory;
     }
 
     ChangeControl create(
@@ -61,17 +67,22 @@
     }
 
     ChangeControl create(RefControl refControl, ChangeNotes notes) {
-      return new ChangeControl(changeDataFactory, refControl, notes);
+      return new ChangeControl(changeDataFactory, identifiedUserFactory, refControl, notes);
     }
   }
 
   private final ChangeData.Factory changeDataFactory;
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final RefControl refControl;
   private final ChangeNotes notes;
 
   private ChangeControl(
-      ChangeData.Factory changeDataFactory, RefControl refControl, ChangeNotes notes) {
+      ChangeData.Factory changeDataFactory,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      RefControl refControl,
+      ChangeNotes notes) {
     this.changeDataFactory = changeDataFactory;
+    this.identifiedUserFactory = identifiedUserFactory;
     this.refControl = refControl;
     this.notes = notes;
   }
@@ -84,7 +95,8 @@
     if (getUser().equals(who)) {
       return this;
     }
-    return new ChangeControl(changeDataFactory, refControl.forUser(who), notes);
+    return new ChangeControl(
+        changeDataFactory, identifiedUserFactory, refControl.forUser(who), notes);
   }
 
   private CurrentUser getUser() {
@@ -261,6 +273,11 @@
     }
 
     @Override
+    public ForChange absentUser(Account.Id id) {
+      return user(identifiedUserFactory.create(id));
+    }
+
+    @Override
     public String resourcePath() {
       if (resourcePath == null) {
         resourcePath =
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index 02eed30..490b45e 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -79,8 +79,8 @@
   }
 
   @Override
-  public WithUser absentUser(Account.Id user) {
-    IdentifiedUser identifiedUser = identifiedUserFactory.create(checkNotNull(user, "user"));
+  public WithUser absentUser(Account.Id id) {
+    IdentifiedUser identifiedUser = identifiedUserFactory.create(checkNotNull(id, "user"));
     return new WithUserImpl(identifiedUser);
   }
 
diff --git a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
index 35b6e0d..431bfd9 100644
--- a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.permissions;
 
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
@@ -129,6 +130,11 @@
     }
 
     @Override
+    public ForProject absentUser(Account.Id id) {
+      return this;
+    }
+
+    @Override
     public String resourcePath() {
       throw new UnsupportedOperationException(
           "FailedPermissionBackend is not scoped to a resource");
@@ -182,6 +188,11 @@
     }
 
     @Override
+    public ForRef absentUser(Account.Id id) {
+      return this;
+    }
+
+    @Override
     public String resourcePath() {
       throw new UnsupportedOperationException(
           "FailedPermissionBackend is not scoped to a resource");
@@ -234,6 +245,11 @@
     }
 
     @Override
+    public ForChange absentUser(Account.Id id) {
+      return this;
+    }
+
+    @Override
     public String resourcePath() {
       throw new UnsupportedOperationException(
           "FailedPermissionBackend is not scoped to a resource");
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 4cbd77e..8cdb61d 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -112,7 +112,7 @@
    *
    * <p>Usage should be very limited as this can expose a group-oracle.
    */
-  public abstract WithUser absentUser(Account.Id user);
+  public abstract WithUser absentUser(Account.Id id);
 
   /**
    * Check whether this {@code PermissionBackend} respects the same global capabilities as the
@@ -305,6 +305,9 @@
     /** Returns a new instance rescoped to same project, but different {@code user}. */
     public abstract ForProject user(CurrentUser user);
 
+    /** @see PermissionBackend#absentUser(Account.Id) */
+    public abstract ForProject absentUser(Account.Id id);
+
     /** Returns an instance scoped for {@code ref} in this project. */
     public abstract ForRef ref(String ref);
 
@@ -413,6 +416,9 @@
     /** Returns a new instance rescoped to same reference, but different {@code user}. */
     public abstract ForRef user(CurrentUser user);
 
+    /** @see PermissionBackend#absentUser(Account.Id) */
+    public abstract ForRef absentUser(Account.Id id);
+
     /** Returns an instance scoped to change. */
     public abstract ForChange change(ChangeData cd);
 
@@ -471,6 +477,9 @@
     /** Returns a new instance rescoped to same change, but different {@code user}. */
     public abstract ForChange user(CurrentUser user);
 
+    /** @see PermissionBackend#absentUser(Account.Id) */
+    public abstract ForChange absentUser(Account.Id id);
+
     /** Verify scoped user can {@code perm}, throwing if denied. */
     public abstract void check(ChangePermissionOrLabel perm)
         throws AuthException, PermissionBackendException;
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index dbd60ea..2d2a64d 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -27,6 +28,7 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
@@ -67,6 +69,7 @@
   private final ChangeControl.Factory changeControlFactory;
   private final PermissionCollection.Factory permissionFilter;
   private final DefaultRefFilter.Factory refFilterFactory;
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
 
   private List<SectionMatcher> allSections;
   private Map<String, RefControl> refControls;
@@ -80,6 +83,7 @@
       ChangeControl.Factory changeControlFactory,
       PermissionBackend permissionBackend,
       DefaultRefFilter.Factory refFilterFactory,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
       @Assisted CurrentUser who,
       @Assisted ProjectState ps) {
     this.changeControlFactory = changeControlFactory;
@@ -88,6 +92,7 @@
     this.permissionFilter = permissionFilter;
     this.permissionBackend = permissionBackend;
     this.refFilterFactory = refFilterFactory;
+    this.identifiedUserFactory = identifiedUserFactory;
     user = who;
     state = ps;
   }
@@ -101,6 +106,7 @@
             changeControlFactory,
             permissionBackend,
             refFilterFactory,
+            identifiedUserFactory,
             who,
             state);
     // Not per-user, and reusing saves lookup time.
@@ -132,7 +138,7 @@
     RefControl ctl = refControls.get(refName);
     if (ctl == null) {
       PermissionCollection relevant = permissionFilter.filter(access(), refName, user);
-      ctl = new RefControl(this, refName, relevant);
+      ctl = new RefControl(identifiedUserFactory, this, refName, relevant);
       refControls.put(refName, ctl);
     }
     return ctl;
@@ -327,6 +333,11 @@
     }
 
     @Override
+    public ForProject absentUser(Account.Id id) {
+      return user(identifiedUserFactory.create(id));
+    }
+
+    @Override
     public String resourcePath() {
       if (resourcePath == null) {
         resourcePath = "/projects/" + getProjectState().getName();
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 28781ea..cd1f84a 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -21,10 +21,12 @@
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
@@ -39,6 +41,7 @@
 
 /** Manages access control for Git references (aka branches, tags). */
 class RefControl {
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final ProjectControl projectControl;
   private final String refName;
 
@@ -52,7 +55,12 @@
   private Boolean canForgeCommitter;
   private Boolean isVisible;
 
-  RefControl(ProjectControl projectControl, String ref, PermissionCollection relevant) {
+  RefControl(
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      ProjectControl projectControl,
+      String ref,
+      PermissionCollection relevant) {
+    this.identifiedUserFactory = identifiedUserFactory;
     this.projectControl = projectControl;
     this.refName = ref;
     this.relevant = relevant;
@@ -71,7 +79,7 @@
     if (relevant.isUserSpecific()) {
       return newCtl.controlForRef(refName);
     }
-    return new RefControl(newCtl, refName, relevant);
+    return new RefControl(identifiedUserFactory, newCtl, refName, relevant);
   }
 
   /** Is this user a ref owner? */
@@ -404,6 +412,11 @@
     }
 
     @Override
+    public ForRef absentUser(Account.Id id) {
+      return user(identifiedUserFactory.create(id));
+    }
+
+    @Override
     public String resourcePath() {
       if (resourcePath == null) {
         resourcePath =
diff --git a/java/com/google/gerrit/server/restapi/RestApiModule.java b/java/com/google/gerrit/server/restapi/RestApiModule.java
new file mode 100644
index 0000000..1ba6f22
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/RestApiModule.java
@@ -0,0 +1,29 @@
+// 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.restapi;
+
+import com.google.inject.AbstractModule;
+
+public class RestApiModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    install(new com.google.gerrit.server.restapi.access.Module());
+    install(new com.google.gerrit.server.restapi.account.Module());
+    install(new com.google.gerrit.server.restapi.change.Module());
+    install(new com.google.gerrit.server.restapi.config.Module());
+    install(new com.google.gerrit.server.restapi.group.Module());
+    install(new com.google.gerrit.server.restapi.project.Module());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index 46955e8..65c7db7 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -98,7 +98,6 @@
   private final AccountLoader.Factory accountLoaderFactory;
   private final Provider<ReviewDb> dbProvider;
   private final ChangeData.Factory changeDataFactory;
-  private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final Config cfg;
   private final ReviewerJson json;
   private final NotesMigration migration;
@@ -118,7 +117,6 @@
       Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       RetryHelper retryHelper,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
       @GerritServerConfig Config cfg,
       ReviewerJson json,
       NotesMigration migration,
@@ -135,7 +133,6 @@
     this.accountLoaderFactory = accountLoaderFactory;
     this.dbProvider = db;
     this.changeDataFactory = changeDataFactory;
-    this.identifiedUserFactory = identifiedUserFactory;
     this.cfg = cfg;
     this.json = json;
     this.migration = migration;
@@ -376,18 +373,18 @@
 
   private boolean isValidReviewer(Account member, PermissionBackend.ForRef perm)
       throws PermissionBackendException {
-    if (member.isActive()) {
-      IdentifiedUser user = identifiedUserFactory.create(member.getId());
-      // Does not account for draft status as a user might want to let a
-      // reviewer see a draft.
-      try {
-        perm.user(user).check(RefPermission.READ);
-        return true;
-      } catch (AuthException e) {
-        return false;
-      }
+    if (!member.isActive()) {
+      return false;
     }
-    return false;
+
+    // Does not account for draft status as a user might want to let a
+    // reviewer see a draft.
+    try {
+      perm.absentUser(member.getId()).check(RefPermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
   }
 
   private Addition fail(String reviewer, String error) {
@@ -464,8 +461,8 @@
       if (migration.readChanges() && state == CC) {
         result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
         for (Account.Id accountId : opResult.addedCCs()) {
-          IdentifiedUser u = identifiedUserFactory.create(accountId);
-          result.ccs.add(json.format(new ReviewerInfo(accountId.get()), perm.user(u), cd));
+          result.ccs.add(
+              json.format(new ReviewerInfo(accountId.get()), perm.absentUser(accountId), cd));
         }
         accountLoaderFactory.create(true).fill(result.ccs);
         for (Address a : reviewersByEmail) {
@@ -475,11 +472,10 @@
         result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
         for (PatchSetApproval psa : opResult.addedReviewers()) {
           // New reviewers have value 0, don't bother normalizing.
-          IdentifiedUser u = identifiedUserFactory.create(psa.getAccountId());
           result.reviewers.add(
               json.format(
                   new ReviewerInfo(psa.getAccountId().get()),
-                  perm.user(u),
+                  perm.absentUser(psa.getAccountId()),
                   cd,
                   ImmutableList.of(psa)));
         }
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index be63e5d..54ecd18 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -18,7 +18,6 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
@@ -33,12 +32,10 @@
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -119,7 +116,6 @@
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
   private final ChangeData.Factory changeDataFactory;
-  private final ChangeMessagesUtil cmUtil;
   private final ChangeNotes.Factory changeNotesFactory;
   private final Provider<MergeOp> mergeOpProvider;
   private final Provider<MergeSuperSet> mergeSuperSet;
@@ -141,7 +137,6 @@
       GitRepositoryManager repoManager,
       PermissionBackend permissionBackend,
       ChangeData.Factory changeDataFactory,
-      ChangeMessagesUtil cmUtil,
       ChangeNotes.Factory changeNotesFactory,
       Provider<MergeOp> mergeOpProvider,
       Provider<MergeSuperSet> mergeSuperSet,
@@ -154,7 +149,6 @@
     this.repoManager = repoManager;
     this.permissionBackend = permissionBackend;
     this.changeDataFactory = changeDataFactory;
-    this.cmUtil = cmUtil;
     this.changeNotesFactory = changeNotesFactory;
     this.mergeOpProvider = mergeOpProvider;
     this.mergeSuperSet = mergeSuperSet;
@@ -237,11 +231,8 @@
       case MERGED:
         return change;
       case NEW:
-        ChangeMessage msg = getConflictMessage(rsrc);
-        if (msg != null) {
-          throw new ResourceConflictException(msg.getMessage());
-        }
-        // $FALL-THROUGH$
+        throw new RestApiException(
+            "change unexpectedly had status " + change.getStatus() + " after submit attempt");
       case ABANDONED:
       default:
         throw new ResourceConflictException("change is " + ChangeUtil.status(change));
@@ -394,18 +385,6 @@
         .setEnabled(Boolean.TRUE.equals(enabled));
   }
 
-  /**
-   * If the merge was attempted and it failed the system usually writes a comment as a ChangeMessage
-   * and sets status to NEW. Find the relevant message and return it.
-   */
-  public ChangeMessage getConflictMessage(RevisionResource rsrc) throws OrmException {
-    return FluentIterable.from(
-            cmUtil.byPatchSet(dbProvider.get(), rsrc.getNotes(), rsrc.getPatchSet().getId()))
-        .filter(cm -> cm.getAuthor() == null)
-        .last()
-        .orNull();
-  }
-
   public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws OrmException, IOException {
     Set<ChangeData> mergeabilityMap = new HashSet<>();
     for (ChangeData change : cs.changes()) {
diff --git a/java/com/google/gerrit/server/restapi/config/ConfigRestModule.java b/java/com/google/gerrit/server/restapi/config/Module.java
similarity index 97%
rename from java/com/google/gerrit/server/restapi/config/ConfigRestModule.java
rename to java/com/google/gerrit/server/restapi/config/Module.java
index 0b94d16..c4a6f56 100644
--- a/java/com/google/gerrit/server/restapi/config/ConfigRestModule.java
+++ b/java/com/google/gerrit/server/restapi/config/Module.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.server.config.CapabilityResource;
 import com.google.gerrit.server.config.TopMenuResource;
 
-public class ConfigRestModule extends RestApiModule {
+public class Module extends RestApiModule {
   @Override
   protected void configure() {
     DynamicMap.mapOf(binder(), CapabilityResource.CAPABILITY_KIND);
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index f2fe4c2..875d636 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -29,9 +29,10 @@
         "//java/com/google/gerrit/server/cache/mem",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
+        "//lib:guava",
         "//lib:gwtorm",
         "//lib:h2",
-        "//lib:truth",
+        "//lib:junit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/guice",
@@ -39,5 +40,6 @@
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/log:api",
+        "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 7d79829..b472857 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -77,6 +77,7 @@
 import com.google.gerrit.server.plugins.PluginRestApiModule;
 import com.google.gerrit.server.plugins.ServerInformationImpl;
 import com.google.gerrit.server.project.DefaultProjectNameLockManager;
+import com.google.gerrit.server.restapi.RestApiModule;
 import com.google.gerrit.server.schema.DataSourceType;
 import com.google.gerrit.server.schema.InMemoryAccountPatchReviewStore;
 import com.google.gerrit.server.schema.NotesMigrationSchemaFactory;
@@ -262,6 +263,7 @@
     }
     bind(ServerInformationImpl.class);
     bind(ServerInformation.class).to(ServerInformationImpl.class);
+    install(new RestApiModule());
     install(new PluginRestApiModule());
     install(new DefaultProjectNameLockManager.Module());
   }
diff --git a/java/com/google/gerrit/truth/BUILD b/java/com/google/gerrit/truth/BUILD
index a0e2ee9..719ddce 100644
--- a/java/com/google/gerrit/truth/BUILD
+++ b/java/com/google/gerrit/truth/BUILD
@@ -4,6 +4,7 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//lib:truth",
+        "//lib:guava",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/BUILD b/javatests/com/google/gerrit/acceptance/BUILD
index 234e4be..9246abb 100644
--- a/javatests/com/google/gerrit/acceptance/BUILD
+++ b/javatests/com/google/gerrit/acceptance/BUILD
@@ -6,7 +6,7 @@
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
         "//lib:guava",
-        "//lib:truth",
         "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/api/group/BUILD b/javatests/com/google/gerrit/acceptance/api/group/BUILD
index 21294f5..a0b70cc 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/group/BUILD
@@ -21,6 +21,6 @@
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//lib:gwtorm",
-        "//lib:truth",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BUILD b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
index 0720fb3..dad3ca9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
@@ -18,7 +18,8 @@
     ],
     deps = [
         "//java/com/google/gerrit/extensions:api",
-        "//lib:truth",
+        "//lib:guava",
+        "//lib/truth",
     ],
 )
 
@@ -31,8 +32,9 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//lib:guava",
         "//lib:gwtorm",
-        "//lib:truth",
+        "//lib/truth",
     ],
 )
 
diff --git a/javatests/com/google/gerrit/common/BUILD b/javatests/com/google/gerrit/common/BUILD
index ff19646..ba9a5bc 100644
--- a/javatests/com/google/gerrit/common/BUILD
+++ b/javatests/com/google/gerrit/common/BUILD
@@ -15,7 +15,7 @@
         "//java/com/google/gerrit/common:client",
         "//lib:guava",
         "//lib:junit",
-        "//lib:truth",
+        "//lib/truth",
     ],
 )
 
@@ -28,8 +28,8 @@
         "//java/com/google/gerrit/common:version",
         "//java/com/google/gerrit/launcher",
         "//lib:guava",
-        "//lib:truth",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
index 70d7089..a2f5229 100644
--- a/javatests/com/google/gerrit/elasticsearch/BUILD
+++ b/javatests/com/google/gerrit/elasticsearch/BUILD
@@ -14,10 +14,10 @@
         "//lib:gson",
         "//lib:guava",
         "//lib:junit",
-        "//lib:truth",
         "//lib/elasticsearch",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/truth",
     ],
 )
 
diff --git a/javatests/com/google/gerrit/extensions/BUILD b/javatests/com/google/gerrit/extensions/BUILD
index 2557750..069c915 100644
--- a/javatests/com/google/gerrit/extensions/BUILD
+++ b/javatests/com/google/gerrit/extensions/BUILD
@@ -7,7 +7,7 @@
     deps = [
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/extensions/common/testing:common-test-util",
-        "//lib:truth",
         "//lib/guice",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/extensions/conditions/BUILD b/javatests/com/google/gerrit/extensions/conditions/BUILD
index aebe347..e2d5951 100644
--- a/javatests/com/google/gerrit/extensions/conditions/BUILD
+++ b/javatests/com/google/gerrit/extensions/conditions/BUILD
@@ -5,6 +5,6 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/extensions:lib",
-        "//lib:truth",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/git/testing/BUILD b/javatests/com/google/gerrit/git/testing/BUILD
index 13eb5bf..56e9ec2 100644
--- a/javatests/com/google/gerrit/git/testing/BUILD
+++ b/javatests/com/google/gerrit/git/testing/BUILD
@@ -5,6 +5,6 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/git/testing",
-        "//lib:truth",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/gpg/BUILD b/javatests/com/google/gerrit/gpg/BUILD
index 5cc9ae8..ab66f9a 100644
--- a/javatests/com/google/gerrit/gpg/BUILD
+++ b/javatests/com/google/gerrit/gpg/BUILD
@@ -20,7 +20,6 @@
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib:gwtorm",
-        "//lib:truth",
         "//lib/bouncycastle:bcpg",
         "//lib/bouncycastle:bcpg-neverlink",
         "//lib/bouncycastle:bcprov",
@@ -30,5 +29,6 @@
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
         "//lib/log:api",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/httpd/BUILD b/javatests/com/google/gerrit/httpd/BUILD
index e2f2a45..ec2df15 100644
--- a/javatests/com/google/gerrit/httpd/BUILD
+++ b/javatests/com/google/gerrit/httpd/BUILD
@@ -19,11 +19,11 @@
         "//lib:junit",
         "//lib:servlet-api-3_1-without-neverlink",
         "//lib:soy",
-        "//lib:truth",
         "//lib/easymock",
         "//lib/guice",
         "//lib/guice:guice-servlet",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/index/BUILD b/javatests/com/google/gerrit/index/BUILD
index bd79860..d905188 100644
--- a/javatests/com/google/gerrit/index/BUILD
+++ b/javatests/com/google/gerrit/index/BUILD
@@ -9,9 +9,10 @@
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index:query_parser",
+        "//lib:guava",
         "//lib:junit",
-        "//lib:truth",
         "//lib/antlr:java_runtime",
         "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/metrics/proc/BUILD b/javatests/com/google/gerrit/metrics/proc/BUILD
index 8e50cf6..91e5cf6 100644
--- a/javatests/com/google/gerrit/metrics/proc/BUILD
+++ b/javatests/com/google/gerrit/metrics/proc/BUILD
@@ -9,8 +9,8 @@
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/metrics/dropwizard",
-        "//lib:truth",
         "//lib/dropwizard:dropwizard-core",
         "//lib/guice",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/pgm/BUILD b/javatests/com/google/gerrit/pgm/BUILD
index af0bea6..e4afae2 100644
--- a/javatests/com/google/gerrit/pgm/BUILD
+++ b/javatests/com/google/gerrit/pgm/BUILD
@@ -13,11 +13,11 @@
         "//java/com/google/gerrit/server",
         "//lib:guava",
         "//lib:junit",
-        "//lib:truth",
         "//lib/easymock",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/truth",
     ],
 )
 
diff --git a/javatests/com/google/gerrit/reviewdb/BUILD b/javatests/com/google/gerrit/reviewdb/BUILD
index a7b9b51..0fd140e 100644
--- a/javatests/com/google/gerrit/reviewdb/BUILD
+++ b/javatests/com/google/gerrit/reviewdb/BUILD
@@ -7,7 +7,8 @@
         "//java/com/google/gerrit/reviewdb:client",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
         "//lib:gwtorm",
-        "//lib:truth",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 3864676..1b11dd65 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -12,7 +12,8 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/truth",
-        "//lib:truth",
+        "//lib:guava",
+        "//lib/truth",
     ],
 )
 
@@ -42,6 +43,7 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/testing",
         "//java/com/google/gerrit/server/group/testing",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/server/restapi",
@@ -51,16 +53,18 @@
         "//java/org/eclipse/jgit:server",
         "//lib:grappa",
         "//lib:gson",
+        "//lib:guava",
         "//lib:guava-retrying",
         "//lib:gwtorm",
         "//lib:protobuf",
-        "//lib:truth-java8-extension",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:codec",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
         "//proto:cache_java_proto",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
new file mode 100644
index 0000000..586c065
--- /dev/null
+++ b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
@@ -0,0 +1,74 @@
+package com.google.gerrit.server.auth.oauth;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.auth.oauth.OAuthToken;
+import com.google.gerrit.server.cache.CacheSerializer;
+import com.google.gerrit.server.cache.proto.Cache.OAuthTokenProto;
+import java.lang.reflect.Type;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class OAuthTokenCacheTest {
+  @Test
+  public void oAuthTokenSerializer() throws Exception {
+    OAuthToken token = new OAuthToken("token", "secret", "raw", 12345L, "provider");
+    CacheSerializer<OAuthToken> s = new OAuthTokenCache.Serializer();
+    byte[] serialized = s.serialize(token);
+    assertThat(OAuthTokenProto.parseFrom(serialized))
+        .isEqualTo(
+            OAuthTokenProto.newBuilder()
+                .setToken("token")
+                .setSecret("secret")
+                .setRaw("raw")
+                .setExpiresAt(12345L)
+                .setProviderId("provider")
+                .build());
+    assertThat(s.deserialize(serialized)).isEqualTo(token);
+  }
+
+  @Test
+  public void oAuthTokenSerializerWithNullProvider() throws Exception {
+    OAuthToken tokenWithNull = new OAuthToken("token", "secret", "raw", 12345L, null);
+    CacheSerializer<OAuthToken> s = new OAuthTokenCache.Serializer();
+    OAuthTokenProto expectedProto =
+        OAuthTokenProto.newBuilder()
+            .setToken("token")
+            .setSecret("secret")
+            .setRaw("raw")
+            .setExpiresAt(12345L)
+            .setProviderId("")
+            .build();
+
+    byte[] serializedWithNull = s.serialize(tokenWithNull);
+    assertThat(OAuthTokenProto.parseFrom(serializedWithNull)).isEqualTo(expectedProto);
+    assertThat(s.deserialize(serializedWithNull)).isEqualTo(tokenWithNull);
+
+    OAuthToken tokenWithEmptyString = new OAuthToken("token", "secret", "raw", 12345L, "");
+    assertThat(tokenWithEmptyString).isEqualTo(tokenWithNull);
+    byte[] serializedWithEmptyString = s.serialize(tokenWithEmptyString);
+    assertThat(OAuthTokenProto.parseFrom(serializedWithEmptyString)).isEqualTo(expectedProto);
+    assertThat(s.deserialize(serializedWithEmptyString)).isEqualTo(tokenWithNull);
+  }
+
+  /**
+   * See {@link com.google.gerrit.server.cache.testing.SerializedClassSubject} for background and
+   * what to do if this test fails.
+   */
+  @Test
+  public void oAuthTokenFields() throws Exception {
+    assertThatSerializedClass(OAuthToken.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("token", String.class)
+                .put("secret", String.class)
+                .put("raw", String.class)
+                .put("expiresAt", long.class)
+                .put("providerId", String.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/BUILD b/javatests/com/google/gerrit/server/cache/BUILD
index eed4a87..278330b 100644
--- a/javatests/com/google/gerrit/server/cache/BUILD
+++ b/javatests/com/google/gerrit/server/cache/BUILD
@@ -6,9 +6,11 @@
     deps = [
         "//java/com/google/gerrit/server",
         "//lib:guava",
+        "//lib:gwtorm",
         "//lib:junit",
-        "//lib:truth",
+        "//lib:protobuf",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/cache/BooleanCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/BooleanCacheSerializerTest.java
new file mode 100644
index 0000000..3186620
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/BooleanCacheSerializerTest.java
@@ -0,0 +1,62 @@
+// 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.cache;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.protobuf.TextFormat;
+import org.junit.Test;
+
+public class BooleanCacheSerializerTest {
+  @Test
+  public void serialize() throws Exception {
+    assertThat(BooleanCacheSerializer.INSTANCE.serialize(true))
+        .isEqualTo(new byte[] {'t', 'r', 'u', 'e'});
+    assertThat(BooleanCacheSerializer.INSTANCE.serialize(false))
+        .isEqualTo(new byte[] {'f', 'a', 'l', 's', 'e'});
+  }
+
+  @Test
+  public void deserialize() throws Exception {
+    assertThat(BooleanCacheSerializer.INSTANCE.deserialize(new byte[] {'t', 'r', 'u', 'e'}))
+        .isEqualTo(true);
+    assertThat(BooleanCacheSerializer.INSTANCE.deserialize(new byte[] {'f', 'a', 'l', 's', 'e'}))
+        .isEqualTo(false);
+  }
+
+  @Test
+  public void deserializeInvalid() throws Exception {
+    assertDeserializeFails(null);
+    assertDeserializeFails("t".getBytes(UTF_8));
+    assertDeserializeFails("tru".getBytes(UTF_8));
+    assertDeserializeFails("trueee".getBytes(UTF_8));
+    assertDeserializeFails("TRUE".getBytes(UTF_8));
+    assertDeserializeFails("f".getBytes(UTF_8));
+    assertDeserializeFails("fal".getBytes(UTF_8));
+    assertDeserializeFails("falseee".getBytes(UTF_8));
+    assertDeserializeFails("FALSE".getBytes(UTF_8));
+  }
+
+  private static void assertDeserializeFails(byte[] in) {
+    try {
+      BooleanCacheSerializer.INSTANCE.deserialize(in);
+      assert_().fail("expected deserialization to fail for \"%s\"", TextFormat.escapeBytes(in));
+    } catch (RuntimeException e) {
+      // Expected.
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java
index 0e04d32..60bbb16 100644
--- a/javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/EnumCacheSerializerTest.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.server.cache;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import org.junit.Test;
 
@@ -26,6 +28,14 @@
     assertRoundTrip(MyEnum.BAZ);
   }
 
+  @Test
+  public void deserializeInvalidValues() throws Exception {
+    assertDeserializeFails(null);
+    assertDeserializeFails("".getBytes(UTF_8));
+    assertDeserializeFails("foo".getBytes(UTF_8));
+    assertDeserializeFails("QUUX".getBytes(UTF_8));
+  }
+
   private enum MyEnum {
     FOO,
     BAR,
@@ -36,4 +46,14 @@
     CacheSerializer<MyEnum> s = new EnumCacheSerializer<>(MyEnum.class);
     assertThat(s.deserialize(s.serialize(e))).isEqualTo(e);
   }
+
+  private static void assertDeserializeFails(byte[] in) {
+    CacheSerializer<MyEnum> s = new EnumCacheSerializer<>(MyEnum.class);
+    try {
+      s.deserialize(in);
+      assert_().fail("expected RuntimeException");
+    } catch (RuntimeException e) {
+      // Expected.
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/server/cache/IntKeyCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/IntKeyCacheSerializerTest.java
new file mode 100644
index 0000000..7a7c27c
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/IntKeyCacheSerializerTest.java
@@ -0,0 +1,66 @@
+// 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.cache;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+
+import com.google.gwtorm.client.IntKey;
+import com.google.gwtorm.client.Key;
+import org.junit.Test;
+
+public class IntKeyCacheSerializerTest {
+
+  private static class MyIntKey extends IntKey<Key<?>> {
+    private static final long serialVersionUID = 1L;
+
+    private int val;
+
+    MyIntKey(int val) {
+      this.val = val;
+    }
+
+    @Override
+    public int get() {
+      return val;
+    }
+
+    @Override
+    protected void set(int newValue) {
+      this.val = newValue;
+    }
+  }
+
+  private static final IntKeyCacheSerializer<MyIntKey> SERIALIZER =
+      new IntKeyCacheSerializer<>(MyIntKey::new);
+
+  @Test
+  public void serialize() throws Exception {
+    MyIntKey k = new MyIntKey(1234);
+    byte[] serialized = SERIALIZER.serialize(k);
+    assertThat(serialized).isEqualTo(new byte[] {-46, 9});
+    assertThat(SERIALIZER.deserialize(serialized).get()).isEqualTo(1234);
+  }
+
+  @Test
+  public void deserializeNullFails() throws Exception {
+    try {
+      SERIALIZER.deserialize(null);
+      assert_().fail("expected RuntimeException");
+    } catch (RuntimeException e) {
+      // Expected.
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/IntegerCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/IntegerCacheSerializerTest.java
new file mode 100644
index 0000000..962b797
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/IntegerCacheSerializerTest.java
@@ -0,0 +1,64 @@
+// 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.cache;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Bytes;
+import com.google.protobuf.TextFormat;
+import org.junit.Test;
+
+public class IntegerCacheSerializerTest {
+  @Test
+  public void serialize() throws Exception {
+    for (int i :
+        ImmutableList.of(
+            Integer.MIN_VALUE,
+            Integer.MIN_VALUE + 20,
+            -1,
+            0,
+            1,
+            Integer.MAX_VALUE - 20,
+            Integer.MAX_VALUE)) {
+      assertRoundTrip(i);
+    }
+  }
+
+  @Test
+  public void deserializeInvalidValues() throws Exception {
+    assertDeserializeFails(null);
+    assertDeserializeFails(
+        Bytes.concat(IntegerCacheSerializer.INSTANCE.serialize(1), new byte[] {0, 0, 0, 0}));
+  }
+
+  private static void assertRoundTrip(int i) throws Exception {
+    byte[] serialized = IntegerCacheSerializer.INSTANCE.serialize(i);
+    int result = IntegerCacheSerializer.INSTANCE.deserialize(serialized);
+    assertThat(result)
+        .named("round-trip of %s via \"%s\"", i, TextFormat.escapeBytes(serialized))
+        .isEqualTo(i);
+  }
+
+  private static void assertDeserializeFails(byte[] in) {
+    try {
+      IntegerCacheSerializer.INSTANCE.deserialize(in);
+      assert_().fail("expected RuntimeException");
+    } catch (RuntimeException e) {
+      // Expected.
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/h2/BUILD b/javatests/com/google/gerrit/server/cache/h2/BUILD
index e2b9257..63ae94b 100644
--- a/javatests/com/google/gerrit/server/cache/h2/BUILD
+++ b/javatests/com/google/gerrit/server/cache/h2/BUILD
@@ -9,7 +9,7 @@
         "//lib:guava",
         "//lib:h2",
         "//lib:junit",
-        "//lib:truth",
         "//lib/guice",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
index 4470f55..5b77094 100644
--- a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.server.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.server.cache.CacheSerializer;
 import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
-import com.google.protobuf.ByteString;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
@@ -45,11 +47,15 @@
     assertThat(s.deserialize(serialized)).isEqualTo(key);
   }
 
-  private static ByteString bytes(int... ints) {
-    byte[] bytes = new byte[ints.length];
-    for (int i = 0; i < ints.length; i++) {
-      bytes[i] = (byte) ints[i];
-    }
-    return ByteString.copyFrom(bytes);
+  /**
+   * See {@link com.google.gerrit.server.cache.testing.SerializedClassSubject} for background and
+   * what to do if this test fails.
+   */
+  @Test
+  public void keyFields() throws Exception {
+    assertThatSerializedClass(ChangeKindCacheImpl.Key.class)
+        .hasFields(
+            ImmutableMap.of(
+                "prior", ObjectId.class, "next", ObjectId.class, "strategyName", String.class));
   }
 }
diff --git a/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java b/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
new file mode 100644
index 0000000..69fc531
--- /dev/null
+++ b/javatests/com/google/gerrit/server/change/MergeabilityCacheImplTest.java
@@ -0,0 +1,69 @@
+// 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.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.bytes;
+import static com.google.gerrit.server.cache.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.server.cache.proto.Cache.MergeabilityKeyProto;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class MergeabilityCacheImplTest {
+  @Test
+  public void keySerializer() throws Exception {
+    MergeabilityCacheImpl.EntryKey key =
+        new MergeabilityCacheImpl.EntryKey(
+            ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"),
+            ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
+            SubmitType.MERGE_IF_NECESSARY,
+            "aStrategy");
+    byte[] serialized = MergeabilityCacheImpl.EntryKey.Serializer.INSTANCE.serialize(key);
+    assertThat(MergeabilityKeyProto.parseFrom(serialized))
+        .isEqualTo(
+            MergeabilityKeyProto.newBuilder()
+                .setCommit(
+                    bytes(
+                        0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee,
+                        0xba, 0xdc, 0x0f, 0xee, 0xba, 0xdc, 0x0f, 0xee))
+                .setInto(
+                    bytes(
+                        0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
+                        0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef))
+                .setSubmitType("MERGE_IF_NECESSARY")
+                .setMergeStrategy("aStrategy")
+                .build());
+    assertThat(MergeabilityCacheImpl.EntryKey.Serializer.INSTANCE.deserialize(serialized))
+        .isEqualTo(key);
+  }
+
+  /**
+   * See {@link com.google.gerrit.server.cache.testing.SerializedClassSubject} for background and
+   * what to do if this test fails.
+   */
+  @Test
+  public void keyFields() throws Exception {
+    assertThatSerializedClass(MergeabilityCacheImpl.EntryKey.class)
+        .hasFields(
+            ImmutableMap.of(
+                "commit", ObjectId.class,
+                "into", ObjectId.class,
+                "submitType", SubmitType.class,
+                "mergeStrategy", String.class));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java b/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
index 935dfc6..fd9c925 100644
--- a/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
+++ b/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
@@ -87,7 +87,7 @@
     }
 
     @Override
-    public WithUser absentUser(Id user) {
+    public WithUser absentUser(Id id) {
       throw new UnsupportedOperationException();
     }
 
diff --git a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
index d242962..834f658 100644
--- a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
+++ b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
@@ -78,6 +78,11 @@
     }
 
     @Override
+    public ForProject absentUser(Account.Id id) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
     public ForRef ref(String ref) {
       throw new UnsupportedOperationException("not implemented");
     }
diff --git a/javatests/com/google/gerrit/server/group/db/BUILD b/javatests/com/google/gerrit/server/group/db/BUILD
index 48e8d303..eee5529 100644
--- a/javatests/com/google/gerrit/server/group/db/BUILD
+++ b/javatests/com/google/gerrit/server/group/db/BUILD
@@ -16,9 +16,10 @@
         "//java/com/google/gerrit/server/group/testing",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/com/google/gerrit/truth",
+        "//lib:guava",
         "//lib:gwtorm",
-        "//lib:truth",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 9b7aad2..d974877 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -442,17 +442,17 @@
     // Change created in WIP remains in WIP.
     RevCommit commit = writeCommit("Update WIP change\n" + "\n" + "Patch-set: 1\n", true);
     ChangeNotesState state = newParser(commit).parseAll();
-    assertThat(state.hasReviewStarted()).isFalse();
+    assertThat(state.columns().hasReviewStarted()).isFalse();
 
     // Moving change out of WIP starts review.
     commit =
         writeCommit("New ready change\n" + "\n" + "Patch-set: 1\n" + "Work-in-progress: false\n");
     state = newParser(commit).parseAll();
-    assertThat(state.hasReviewStarted()).isTrue();
+    assertThat(state.columns().hasReviewStarted()).isTrue();
 
     // Change created not in WIP has always been in review started state.
     state = assertParseSucceeds("New change that doesn't declare WIP\n" + "\n" + "Patch-set: 1\n");
-    assertThat(state.hasReviewStarted()).isTrue();
+    assertThat(state.columns().hasReviewStarted()).isTrue();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index e5a34aa..9d38704 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -1078,7 +1078,6 @@
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getPatchSets().keySet()).containsExactly(psId1, psId2);
     assertThat(notes.getApprovals()).isNotEmpty();
-    assertThat(notes.getChangeMessagesByPatchSet()).isNotEmpty();
     assertThat(notes.getChangeMessages()).isNotEmpty();
     assertThat(notes.getComments()).isNotEmpty();
 
@@ -1095,7 +1094,6 @@
     notes = newNotes(c);
     assertThat(notes.getPatchSets().keySet()).containsExactly(psId1);
     assertThat(notes.getApprovals()).isEmpty();
-    assertThat(notes.getChangeMessagesByPatchSet()).isEmpty();
     assertThat(notes.getChangeMessages()).isEmpty();
     assertThat(notes.getComments()).isEmpty();
   }
@@ -1349,16 +1347,12 @@
     update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
     update.setChangeMessage("Just a little code change.\n");
     update.commit();
-    PatchSet.Id ps1 = c.currentPatchSetId();
 
     ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
-    assertThat(changeMessages.keySet()).containsExactly(ps1);
-
-    ChangeMessage cm = Iterables.getOnlyElement(changeMessages.get(ps1));
+    ChangeMessage cm = Iterables.getOnlyElement(notes.getChangeMessages());
     assertThat(cm.getMessage()).isEqualTo("Just a little code change.\n");
     assertThat(cm.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
-    assertThat(cm.getPatchSetId()).isEqualTo(ps1);
+    assertThat(cm.getPatchSetId()).isEqualTo(c.currentPatchSetId());
   }
 
   @Test
@@ -1378,13 +1372,9 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setChangeMessage("Testing trailing double newline\n\n");
     update.commit();
-    PatchSet.Id ps1 = c.currentPatchSetId();
 
     ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
-    assertThat(changeMessages).hasSize(1);
-
-    ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
+    ChangeMessage cm1 = Iterables.getOnlyElement(notes.getChangeMessages());
     assertThat(cm1.getMessage()).isEqualTo("Testing trailing double newline\n\n");
     assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
   }
@@ -1395,13 +1385,9 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setChangeMessage("Testing paragraph 1\n\nTesting paragraph 2\n\nTesting paragraph 3");
     update.commit();
-    PatchSet.Id ps1 = c.currentPatchSetId();
 
     ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
-    assertThat(changeMessages).hasSize(1);
-
-    ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
+    ChangeMessage cm1 = Iterables.getOnlyElement(notes.getChangeMessages());
     assertThat(cm1.getMessage())
         .isEqualTo(
             "Testing paragraph 1\n"
@@ -1429,15 +1415,15 @@
     PatchSet.Id ps2 = c.currentPatchSetId();
 
     ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
-    assertThat(changeMessages).hasSize(2);
+    assertThat(notes.getChangeMessages()).hasSize(2);
 
-    ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
+    ChangeMessage cm1 = notes.getChangeMessages().get(0);
+    assertThat(cm1.getPatchSetId()).isEqualTo(ps1);
     assertThat(cm1.getMessage()).isEqualTo("This is the change message for the first PS.");
     assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
 
-    ChangeMessage cm2 = Iterables.getOnlyElement(changeMessages.get(ps2));
-    assertThat(cm1.getPatchSetId()).isEqualTo(ps1);
+    ChangeMessage cm2 = notes.getChangeMessages().get(1);
+    assertThat(cm2.getPatchSetId()).isEqualTo(ps2);
     assertThat(cm2.getMessage()).isEqualTo("This is the change message for the second PS.");
     assertThat(cm2.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
     assertThat(cm2.getPatchSetId()).isEqualTo(ps2);
@@ -1459,10 +1445,8 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
-    assertThat(changeMessages.keySet()).hasSize(1);
 
-    List<ChangeMessage> cm = changeMessages.get(ps1);
+    List<ChangeMessage> cm = notes.getChangeMessages();
     assertThat(cm).hasSize(2);
     assertThat(cm.get(0).getMessage()).isEqualTo("First change message.\n");
     assertThat(cm.get(0).getAuthor()).isEqualTo(changeOwner.getAccount().getId());
@@ -3266,7 +3250,7 @@
   public void privateDefault() throws Exception {
     Change c = newChange();
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.isPrivate()).isFalse();
+    assertThat(notes.getChange().isPrivate()).isFalse();
   }
 
   @Test
@@ -3277,7 +3261,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.isPrivate()).isTrue();
+    assertThat(notes.getChange().isPrivate()).isTrue();
   }
 
   @Test
@@ -3292,7 +3276,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.isPrivate()).isFalse();
+    assertThat(notes.getChange().isPrivate()).isFalse();
   }
 
   @Test
@@ -3397,38 +3381,38 @@
   @Test
   public void hasReviewStarted() throws Exception {
     ChangeNotes notes = newNotes(newChange());
-    assertThat(notes.hasReviewStarted()).isTrue();
+    assertThat(notes.getChange().hasReviewStarted()).isTrue();
 
     notes = newNotes(newWorkInProgressChange());
-    assertThat(notes.hasReviewStarted()).isFalse();
+    assertThat(notes.getChange().hasReviewStarted()).isFalse();
 
     Change c = newWorkInProgressChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.commit();
     notes = newNotes(c);
-    assertThat(notes.hasReviewStarted()).isFalse();
+    assertThat(notes.getChange().hasReviewStarted()).isFalse();
 
     update = newUpdate(c, changeOwner);
     update.setWorkInProgress(true);
     update.commit();
     notes = newNotes(c);
-    assertThat(notes.hasReviewStarted()).isFalse();
+    assertThat(notes.getChange().hasReviewStarted()).isFalse();
 
     update = newUpdate(c, changeOwner);
     update.setWorkInProgress(false);
     update.commit();
     notes = newNotes(c);
-    assertThat(notes.hasReviewStarted()).isTrue();
+    assertThat(notes.getChange().hasReviewStarted()).isTrue();
 
     // Once review is started, setting WIP should have no impact.
     c = newChange();
     notes = newNotes(c);
-    assertThat(notes.hasReviewStarted()).isTrue();
+    assertThat(notes.getChange().hasReviewStarted()).isTrue();
     update = newUpdate(c, changeOwner);
     update.setWorkInProgress(true);
     update.commit();
     notes = newNotes(c);
-    assertThat(notes.hasReviewStarted()).isTrue();
+    assertThat(notes.getChange().hasReviewStarted()).isTrue();
   }
 
   @Test
@@ -3493,7 +3477,7 @@
   public void revertOfIsNullByDefault() throws Exception {
     Change c = newChange();
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getRevertOf()).isNull();
+    assertThat(notes.getChange().getRevertOf()).isNull();
   }
 
   @Test
@@ -3504,7 +3488,7 @@
     update.setChangeId(c.getKey().get());
     update.setRevertOf(changeToRevert.getId().get());
     update.commit();
-    assertThat(newNotes(c).getRevertOf()).isEqualTo(changeToRevert.getId());
+    assertThat(newNotes(c).getChange().getRevertOf()).isEqualTo(changeToRevert.getId());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index c30803a..7890de8 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -47,6 +47,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.CapabilityCollection;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
@@ -202,6 +203,7 @@
   @Inject private InMemoryDatabase schemaFactory;
   @Inject private ThreadLocalRequestContext requestContext;
   @Inject private DefaultRefFilter.Factory refFilterFactory;
+  @Inject private IdentifiedUser.GenericFactory identifiedUserFactory;
 
   @Before
   public void setUp() throws Exception {
@@ -986,6 +988,7 @@
         changeControlFactory,
         permissionBackend,
         refFilterFactory,
+        identifiedUserFactory,
         new MockUser(name, memberOf),
         newProjectState(local));
   }
diff --git a/javatests/com/google/gerrit/server/query/account/BUILD b/javatests/com/google/gerrit/server/query/account/BUILD
index c352f43..e6c631b 100644
--- a/javatests/com/google/gerrit/server/query/account/BUILD
+++ b/javatests/com/google/gerrit/server/query/account/BUILD
@@ -15,10 +15,11 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//lib:truth",
-        "//lib:truth-java8-extension",
+        "//lib:guava",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
         "//prolog:gerrit-prolog-common",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index 66c825c..78ec176 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -19,11 +19,12 @@
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
         "//lib:gwtorm",
-        "//lib:truth",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/truth",
     ],
 )
 
@@ -41,10 +42,11 @@
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
         "//lib:gwtorm",
-        "//lib:truth",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/query/group/BUILD b/javatests/com/google/gerrit/server/query/group/BUILD
index 01a54a3..0dd16cd 100644
--- a/javatests/com/google/gerrit/server/query/group/BUILD
+++ b/javatests/com/google/gerrit/server/query/group/BUILD
@@ -15,10 +15,11 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//lib:truth",
-        "//lib:truth-java8-extension",
+        "//lib:guava",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
     ],
 )
 
diff --git a/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
index ac2692b..eaa3df3 100644
--- a/javatests/com/google/gerrit/server/query/project/BUILD
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -14,9 +14,10 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//lib:truth",
+        "//lib:guava",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/truth",
     ],
 )
 
diff --git a/javatests/com/google/gerrit/server/rules/BUILD b/javatests/com/google/gerrit/server/rules/BUILD
index 04a6485..42452df 100644
--- a/javatests/com/google/gerrit/server/rules/BUILD
+++ b/javatests/com/google/gerrit/server/rules/BUILD
@@ -10,10 +10,10 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//lib:truth",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/prolog:runtime",
+        "//lib/truth",
         "//prolog:gerrit-prolog-common",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
index 81e8b31..46820c7 100644
--- a/javatests/com/google/gerrit/server/update/BUILD
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -12,9 +12,9 @@
         "//java/com/google/gerrit/server",
         "//lib:guava",
         "//lib:junit",
-        "//lib:truth",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/truth",
     ],
 )
 
@@ -34,10 +34,10 @@
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib:gwtorm",
-        "//lib:truth",
-        "//lib:truth-java8-extension",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
     ],
 )
diff --git a/javatests/com/google/gerrit/sshd/BUILD b/javatests/com/google/gerrit/sshd/BUILD
index c0eaedf..ad7d8a9 100644
--- a/javatests/com/google/gerrit/sshd/BUILD
+++ b/javatests/com/google/gerrit/sshd/BUILD
@@ -7,7 +7,7 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/sshd",
-        "//lib:truth",
         "//lib/mina:sshd",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/testing/BUILD b/javatests/com/google/gerrit/testing/BUILD
index 191e98f..5774707 100644
--- a/javatests/com/google/gerrit/testing/BUILD
+++ b/javatests/com/google/gerrit/testing/BUILD
@@ -7,6 +7,6 @@
     deps = [
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//lib:truth",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/util/http/BUILD b/javatests/com/google/gerrit/util/http/BUILD
index 5755ca8..48b4339 100644
--- a/javatests/com/google/gerrit/util/http/BUILD
+++ b/javatests/com/google/gerrit/util/http/BUILD
@@ -8,7 +8,7 @@
         "//javatests/com/google/gerrit/util/http/testutil",
         "//lib:junit",
         "//lib:servlet-api-3_1-without-neverlink",
-        "//lib:truth",
         "//lib/easymock",
+        "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gwtexpui/safehtml/BUILD b/javatests/com/google/gwtexpui/safehtml/BUILD
index 4f75bdb..694f422 100644
--- a/javatests/com/google/gwtexpui/safehtml/BUILD
+++ b/javatests/com/google/gwtexpui/safehtml/BUILD
@@ -5,8 +5,9 @@
     srcs = glob(["client/**/*.java"]),
     deps = [
         "//java/com/google/gwtexpui/safehtml",
-        "//lib:truth",
+        "//lib:guava",
         "//lib/gwt:dev",
         "//lib/gwt:user",
+        "//lib/truth",
     ],
 )
diff --git a/lib/BUILD b/lib/BUILD
index 5e391e9..c698afb 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -217,28 +217,6 @@
 )
 
 java_library(
-    name = "truth",
-    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
-    visibility = ["//visibility:public"],
-    exports = [
-        ":guava",
-        ":junit",
-        "@truth//jar",
-    ],
-)
-
-java_library(
-    name = "truth-java8-extension",
-    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
-    visibility = ["//visibility:public"],
-    exports = [
-        ":guava",
-        ":truth",
-        "@truth-java8-extension//jar",
-    ],
-)
-
-java_library(
     name = "javassist",
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
     visibility = ["//visibility:public"],
diff --git a/lib/guava.bzl b/lib/guava.bzl
index db85900..e90c2b3 100644
--- a/lib/guava.bzl
+++ b/lib/guava.bzl
@@ -1,5 +1,5 @@
-GUAVA_VERSION = "24.1-jre"
+GUAVA_VERSION = "25.0-jre"
 
-GUAVA_BIN_SHA1 = "96c528475465aeb22cce60605d230a7e67cebd7b"
+GUAVA_BIN_SHA1 = "7319c34fa5866a85b6bad445adad69d402323129"
 
 GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
diff --git a/lib/truth/BUILD b/lib/truth/BUILD
new file mode 100644
index 0000000..cb17269
--- /dev/null
+++ b/lib/truth/BUILD
@@ -0,0 +1,21 @@
+java_library(
+    name = "truth",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = ["@truth//jar"],
+    runtime_deps = [
+        "//lib:guava",
+        "//lib:junit",
+    ],
+)
+
+java_library(
+    name = "truth-java8-extension",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = ["@truth-java8-extension//jar"],
+    runtime_deps = [
+        ":truth",
+        "//lib:guava",
+    ],
+)
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index b59742e..3f26628 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -283,6 +283,7 @@
           as="file"
           initial-count="[[fileListIncrement]]"
           target-framerate="1">
+        [[_reportRenderedRow(index)]]
         <div class="stickyArea">
           <div class$="file-row row [[_computePathClass(file.__path, _expandedFilePaths.*)]]"
               data-path$="[[file.__path]]" tabindex="-1">
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index 0fa037c..83f1565 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -26,6 +26,8 @@
   const SIZE_BAR_GAP_WIDTH = 1;
   const SIZE_BAR_MIN_WIDTH = 1.5;
 
+  const RENDER_TIME = 'FileListRenderTime';
+
   const FileStatus = {
     A: 'Added',
     C: 'Copied',
@@ -429,17 +431,21 @@
       return GrCountStringFormatter.computeShortString(commentCount, 'c');
     },
 
-    _reviewFile(path) {
+    /**
+     * @param {string} path
+     * @param {boolean=} opt_reviewed
+     */
+    _reviewFile(path, opt_reviewed) {
       if (this.editMode) { return; }
       const index = this._files.findIndex(file => file.__path === path);
-      const reviewed = this._files[index].isReviewed;
+      const reviewed = opt_reviewed || !this._files[index].isReviewed;
 
-      this.set(['_files', index, 'isReviewed'], !reviewed);
+      this.set(['_files', index, 'isReviewed'], reviewed);
       if (index < this._shownFiles.length) {
-        this.set(['_shownFiles', index, 'isReviewed'], !reviewed);
+        this.set(['_shownFiles', index, 'isReviewed'], reviewed);
       }
 
-      this._saveReviewedState(path, !reviewed);
+      this._saveReviewedState(path, reviewed);
     },
 
     _saveReviewedState(path, reviewed) {
@@ -797,6 +803,12 @@
     _computeFilesShown(numFilesShown, files) {
       const filesShown = files.base.slice(0, numFilesShown);
       this.fire('files-shown-changed', {length: filesShown.length});
+
+      // Start the timer for the rendering work hwere because this is where the
+      // _shownFiles property is being set, and _shownFiles is used in the
+      // dom-repeat binding.
+      this.$.reporting.time(RENDER_TIME);
+
       return filesShown;
     },
 
@@ -953,7 +965,7 @@
               path, this.patchRange, this.projectConfig);
           const promises = [diffElem.reload()];
           if (this._loggedIn && !this.diffPrefs.manual_review) {
-            promises.push(this._reviewFile(path));
+            promises.push(this._reviewFile(path, true));
           }
           return Promise.all(promises);
         }).then(() => {
@@ -1175,5 +1187,21 @@
     _noDiffsExpanded() {
       return this.filesExpanded === GrFileListConstants.FilesExpandedState.NONE;
     },
+
+    /**
+     * Method to call via binding when each file list row is rendered. This
+     * allows approximate detection of when the dom-repeat has completed
+     * rendering.
+     * @param {number} index The index of the row being rendered.
+     * @return {string} an empty string.
+     */
+    _reportRenderedRow(index) {
+      if (index === this._shownFiles.length - 1) {
+        this.async(() => {
+          this.$.reporting.timeEnd(RENDER_TIME);
+        }, 1);
+      }
+      return '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index 8541edf..3c90a1f 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -60,7 +60,6 @@
       stub('gr-rest-api-interface', {
         getLoggedIn() { return Promise.resolve(true); },
         getPreferences() { return Promise.resolve({}); },
-        fetchJSON() { return Promise.resolve({}); },
         getDiffComments() { return Promise.resolve({}); },
         getDiffRobotComments() { return Promise.resolve({}); },
         getDiffDrafts() { return Promise.resolve({}); },
@@ -127,6 +126,19 @@
       assert.isTrue(controlRow.classList.contains('invisible'));
     });
 
+    test('rendering each row calls the _reportRenderedRow method', () => {
+      const renderedStub = sandbox.stub(element, '_reportRenderedRow');
+      element._filesByPath = _.range(10)
+          .reduce((_filesByPath, i) => {
+            _filesByPath['/file' + i] = {lines_inserted: 9};
+            return _filesByPath;
+          }, {});
+      flushAsynchronousOperations();
+      assert.equal(
+          Polymer.dom(element.root).querySelectorAll('.file-row').length, 10);
+      assert.equal(renderedStub.callCount, 10);
+    });
+
     test('calculate totals for patch number', () => {
       element._filesByPath = {
         '/COMMIT_MSG': {
@@ -1023,6 +1035,7 @@
         delete element.diffPrefs.manual_review;
         return element._renderInOrder(['p'], diffs, 1).then(() => {
           assert.isTrue(reviewStub.called);
+          assert.isTrue(reviewStub.calledWithExactly('p', true));
         });
       });
     });
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
index fb8f6ec..8ce59f2 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
@@ -52,7 +52,6 @@
         content: "";
         display: inline-block;
         height: var(--header-icon-size);
-        margin: 0 .25em 0 0;
         vertical-align: text-bottom;
         width: var(--header-icon-size);
       }
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector.js
new file mode 100644
index 0000000..28c46f4
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector.js
@@ -0,0 +1,61 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  const JANK_SLEEP_TIME_MS = 1000;
+
+  const GrJankDetector = {
+    // Slowdowns counter.
+    jank: 0,
+    fps: 0,
+    _lastFrameTime: 0,
+
+    start() {
+      this._requestAnimationFrame(this._detect.bind(this));
+    },
+
+    _requestAnimationFrame(callback) {
+      window.requestAnimationFrame(callback);
+    },
+
+    _detect(now) {
+      if (this._lastFrameTime === 0) {
+        this._lastFrameTime = now;
+        this.fps = 0;
+        this._requestAnimationFrame(this._detect.bind(this));
+        return;
+      }
+      const fpsNow = 1000/(now - this._lastFrameTime);
+      this._lastFrameTime = now;
+      // Calculate moving average within last 3 measurements.
+      this.fps = this.fps === 0 ? fpsNow : ((this.fps * 2 + fpsNow) / 3);
+      if (this.fps > 10) {
+        this._requestAnimationFrame(this._detect.bind(this));
+      } else {
+        this.jank++;
+        console.warn('JANK', this.jank);
+        this._lastFrameTime = 0;
+        window.setTimeout(
+            () => this._requestAnimationFrame(this._detect.bind(this)),
+            JANK_SLEEP_TIME_MS);
+      }
+    },
+  };
+
+  window.GrJankDetector = GrJankDetector;
+})();
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector_test.html
new file mode 100644
index 0000000..6faeec1
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-jank-detector_test.html
@@ -0,0 +1,78 @@
+<!DOCTYPE html>
+<!--
+@license
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-jank-detector</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+
+<script src="gr-jank-detector.js"></script>
+
+<script>
+  suite('gr-jank-detector tests', () => {
+    let sandbox;
+    let clock;
+    let instance;
+
+    const NOW_TIME = 100;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      clock = sinon.useFakeTimers(NOW_TIME);
+      instance = GrJankDetector;
+      instance._lastFrameTime = 0;
+      sandbox.stub(instance, '_requestAnimationFrame');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('start() installs frame callback', () => {
+      sandbox.stub(instance, '_detect');
+      instance._requestAnimationFrame.callsArg(0);
+      instance.start();
+      assert.isTrue(instance._detect.calledOnce);
+    });
+
+    test('measures fps', () => {
+      instance._detect(10);
+      instance._detect(30);
+      assert.equal(instance.fps, 50);
+    });
+
+    test('detects jank', () => {
+      let now = 10;
+      instance._detect(now);
+      const fastFrame = () => instance._detect(now += 20);
+      const slowFrame = () => instance._detect(now += 300);
+      fastFrame();
+      assert.equal(instance.jank, 0);
+      _.times(4, slowFrame);
+      assert.equal(instance.jank, 0);
+      instance._requestAnimationFrame.reset();
+      slowFrame();
+      assert.equal(instance.jank, 1);
+      assert.isFalse(instance._requestAnimationFrame.called);
+      clock.tick(1000);
+      assert.isTrue(instance._requestAnimationFrame.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
index 2970a26..cbb2c09 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
@@ -19,5 +19,6 @@
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 
 <dom-module id="gr-reporting">
+  <script src="gr-jank-detector.js"></script>
   <script src="gr-reporting.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
index 0db442f..ae67dac 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -48,6 +48,14 @@
     STARTED_HIDDEN: 'hidden',
   };
 
+  // Frame rate related constants.
+  const JANK = {
+    TYPE: 'lifecycle',
+    CATEGORY: 'UI Latency',
+    // Reported events - alphabetize below.
+    COUNT: 'Jank count',
+  };
+
   // Navigation reporting constants.
   const NAVIGATION = {
     TYPE: 'nav-report',
@@ -118,6 +126,8 @@
   };
   catchErrors();
 
+  GrJankDetector.start();
+
   const GrReporting = Polymer({
     is: 'gr-reporting',
 
@@ -206,6 +216,11 @@
     },
 
     beforeLocationChanged() {
+      if (GrJankDetector.jank > 0) {
+        this.reporter(
+            JANK.TYPE, JANK.CATEGORY, JANK.COUNT, GrJankDetector.jank);
+        GrJankDetector.jank = 0;
+      }
       for (const prop of Object.keys(this._baselines)) {
         delete this._baselines[prop];
       }
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
index bfb45f6..e2bb83d 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
@@ -93,7 +93,11 @@
     test('beforeLocationChanged', () => {
       element._baselines['garbage'] = 'monster';
       sandbox.stub(element, 'time');
+      GrJankDetector.jank = 42;
       element.beforeLocationChanged();
+      assert.equal(GrJankDetector.jank, 0);
+      assert.isTrue(element.reporter.calledWithExactly(
+          'lifecycle', 'UI Latency', 'Jank count', 42));
       assert.isTrue(element.time.calledWithExactly('DashboardDisplayed'));
       assert.isTrue(element.time.calledWithExactly('ChangeDisplayed'));
       assert.isTrue(element.time.calledWithExactly('DiffViewDisplayed'));
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
index cda8a67..f527aa3 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
@@ -145,6 +145,7 @@
                 <a
                     class="itemAction"
                     href$="[[_computeLinkURL(link)]]"
+                    download$="[[_computeIsDownload(link)]]"
                     rel$="[[_computeLinkRel(link)]]"
                     target$="[[link.target]]"
                     hidden$="[[!link.url]]"
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index 70534f0..dcb428f 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -286,5 +286,9 @@
     _computeHasTooltip(tooltip) {
       return !!tooltip;
     },
+
+    _computeIsDownload(link) {
+      return !!link.download;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
index 89b6068..456f235 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
@@ -49,6 +49,11 @@
       sandbox.restore();
     });
 
+    test('_computeIsDownload', () => {
+      assert.isTrue(element._computeIsDownload({download: true}));
+      assert.isFalse(element._computeIsDownload({download: false}));
+    });
+
     test('tap on trigger opens menu, then closes', () => {
       sandbox.stub(element, '_open', () => { element.$.dropdown.open(); });
       sandbox.stub(element, '_close', () => { element.$.dropdown.close(); });
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 9dc51ba..9a5851b 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -27,6 +27,36 @@
    */
   Defs.patchRange;
 
+  /**
+   * Object to describe a request for passing into _fetchJSON or _fetchRawJSON.
+   * - url is the URL for the request (excluding get params)
+   * - errFn is a function to invoke when the request fails.
+   * - cancelCondition is a function that, if provided and returns true, will
+   *     cancel the response after it resolves.
+   * - params is a key-value hash to specify get params for the request URL.
+   * @typedef {{
+   *    url: string,
+   *    errFn: (function(?Response, string=)|null|undefined),
+   *    cancelCondition: (function()|null|undefined),
+   *    params: (Object|null|undefined),
+   *    fetchOptions: (Object|null|undefined),
+   * }}
+   */
+  Defs.FetchJSONRequest;
+
+  /**
+   * @typedef {{
+   *   changeNum: (string|number),
+   *   endpoint: string,
+   *   patchNum: (string|number|null|undefined),
+   *   errFn: (function(?Response, string=)|null|undefined),
+   *   cancelCondition: (function()|null|undefined),
+   *   params: (Object|null|undefined),
+   *   fetchOptions: (Object|null|undefined),
+   * }}
+   */
+  Defs.ChangeFetchRequest;
+
   const DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
     UNIFIED: 'UNIFIED_DIFF',
@@ -112,23 +142,17 @@
      * Returns a Promise that resolves to a native Response.
      * Doesn't do error checking. Supports cancel condition. Performs auth.
      * Validates auth expiry errors.
-     * @param {string} url
-     * @param {?function(?Response, string=)=} opt_errFn
-     *    passed as null sometimes.
-     * @param {?function()=} opt_cancelCondition
-     *    passed as null sometimes.
-     * @param {?Object=} opt_params URL params, key-value hash.
-     * @param {?Object=} opt_options Fetch options.
+     * @param {Defs.FetchJSONRequest} req
+     * @return {Promise}
      */
-    _fetchRawJSON(url, opt_errFn, opt_cancelCondition, opt_params,
-        opt_options) {
-      const urlWithParams = this._urlWithParams(url, opt_params);
-      return this._auth.fetch(urlWithParams, opt_options).then(response => {
-        if (opt_cancelCondition && opt_cancelCondition()) {
-          response.body.cancel();
+    _fetchRawJSON(req) {
+      const urlWithParams = this._urlWithParams(req.url, req.params);
+      return this._auth.fetch(urlWithParams, req.fetchOptions).then(res => {
+        if (req.cancelCondition && req.cancelCondition()) {
+          res.body.cancel();
           return;
         }
-        return response;
+        return res;
       }).catch(err => {
         const isLoggedIn = !!this._cache['/accounts/self/detail'];
         if (isLoggedIn && err && err.message === FAILED_TO_FETCH_ERROR) {
@@ -139,8 +163,8 @@
               CHECK_SIGN_IN_DEBOUNCE_MS);
           return;
         }
-        if (opt_errFn) {
-          opt_errFn.call(undefined, null, err);
+        if (req.errFn) {
+          req.errFn.call(undefined, null, err);
         } else {
           this.fire('network-error', {error: err});
         }
@@ -152,31 +176,23 @@
      * Fetch JSON from url provided.
      * Returns a Promise that resolves to a parsed response.
      * Same as {@link _fetchRawJSON}, plus error handling.
-     * @param {string} url
-     * @param {?function(?Response, string=)=} opt_errFn
-     *    passed as null sometimes.
-     * @param {?function()=} opt_cancelCondition
-     *    passed as null sometimes.
-     * @param {?Object=} opt_params URL params, key-value hash.
-     * @param {?Object=} opt_options Fetch options.
+     * @param {Defs.FetchJSONRequest} req
      */
-    fetchJSON(url, opt_errFn, opt_cancelCondition, opt_params, opt_options) {
-      return this._fetchRawJSON(
-          url, opt_errFn, opt_cancelCondition, opt_params, opt_options)
-          .then(response => {
-            if (!response) {
-              return;
-            }
-            if (!response.ok) {
-              if (opt_errFn) {
-                opt_errFn.call(null, response);
-                return;
-              }
-              this.fire('server-error', {response});
-              return;
-            }
-            return response && this.getResponseObject(response);
-          });
+    _fetchJSON(req) {
+      return this._fetchRawJSON(req).then(response => {
+        if (!response) {
+          return;
+        }
+        if (!response.ok) {
+          if (req.errFn) {
+            req.errFn.call(null, response);
+            return;
+          }
+          this.fire('server-error', {response});
+          return;
+        }
+        return response && this.getResponseObject(response);
+      });
     },
 
     /**
@@ -236,39 +252,45 @@
 
     getConfig(noCache) {
       if (!noCache) {
-        return this._fetchSharedCacheURL('/config/server/info');
+        return this._fetchSharedCacheURL({url: '/config/server/info'});
       }
 
-      return this.fetchJSON('/config/server/info');
+      return this._fetchJSON({url: '/config/server/info'});
     },
 
     getRepo(repo, opt_errFn) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchSharedCacheURL(
-          '/projects/' + encodeURIComponent(repo), opt_errFn);
+      return this._fetchSharedCacheURL({
+        url: '/projects/' + encodeURIComponent(repo),
+        errFn: opt_errFn,
+      });
     },
 
     getProjectConfig(repo, opt_errFn) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchSharedCacheURL(
-          '/projects/' + encodeURIComponent(repo) + '/config', opt_errFn);
+      return this._fetchSharedCacheURL({
+        url: '/projects/' + encodeURIComponent(repo) + '/config',
+        errFn: opt_errFn,
+      });
     },
 
     getRepoAccess(repo) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchSharedCacheURL(
-          '/access/?project=' + encodeURIComponent(repo));
+      return this._fetchSharedCacheURL({
+        url: '/access/?project=' + encodeURIComponent(repo),
+      });
     },
 
     getRepoDashboards(repo, opt_errFn) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchSharedCacheURL(
-          `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
-          opt_errFn);
+      return this._fetchSharedCacheURL({
+        url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
+        errFn: opt_errFn,
+      });
     },
 
     saveRepoConfig(repo, config, opt_errFn, opt_ctx) {
@@ -315,8 +337,10 @@
     },
 
     getGroupConfig(group, opt_errFn) {
-      const encodeName = encodeURIComponent(group);
-      return this.fetchJSON(`/groups/${encodeName}/detail`, opt_errFn);
+      return this._fetchJSON({
+        url: `/groups/${encodeURIComponent(group)}/detail`,
+        errFn: opt_errFn,
+      });
     },
 
     /**
@@ -394,7 +418,7 @@
      */
     getIsGroupOwner(groupName) {
       const encodeName = encodeURIComponent(groupName);
-      return this._fetchSharedCacheURL(`/groups/?owned&q=${encodeName}`)
+      return this._fetchSharedCacheURL({url: `/groups/?owned&q=${encodeName}`})
           .then(configs => configs.hasOwnProperty(groupName));
     },
 
@@ -432,8 +456,10 @@
     },
 
     getGroupAuditLog(group, opt_errFn) {
-      return this._fetchSharedCacheURL(
-          '/groups/' + group + '/log.audit', opt_errFn);
+      return this._fetchSharedCacheURL({
+        url: '/groups/' + group + '/log.audit',
+        errFn: opt_errFn,
+      });
     },
 
     saveGroupMembers(groupName, groupMembers) {
@@ -470,13 +496,15 @@
     },
 
     getVersion() {
-      return this._fetchSharedCacheURL('/config/server/version');
+      return this._fetchSharedCacheURL({url: '/config/server/version'});
     },
 
     getDiffPreferences() {
       return this.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
-          return this._fetchSharedCacheURL('/accounts/self/preferences.diff');
+          return this._fetchSharedCacheURL({
+            url: '/accounts/self/preferences.diff',
+          });
         }
         // These defaults should match the defaults in
         // java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -504,7 +532,9 @@
     getEditPreferences() {
       return this.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
-          return this._fetchSharedCacheURL('/accounts/self/preferences.edit');
+          return this._fetchSharedCacheURL({
+            url: '/accounts/self/preferences.edit',
+          });
         }
         // These defaults should match the defaults in
         // java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
@@ -570,15 +600,18 @@
     },
 
     getAccount() {
-      return this._fetchSharedCacheURL('/accounts/self/detail', resp => {
-        if (!resp || resp.status === 403) {
-          this._cache['/accounts/self/detail'] = null;
-        }
+      return this._fetchSharedCacheURL({
+        url: '/accounts/self/detail',
+        errFn: resp => {
+          if (!resp || resp.status === 403) {
+            this._cache['/accounts/self/detail'] = null;
+          }
+        },
       });
     },
 
     getExternalIds() {
-      return this.fetchJSON('/accounts/self/external.ids');
+      return this._fetchJSON({url: '/accounts/self/external.ids'});
     },
 
     deleteAccountIdentity(id) {
@@ -591,11 +624,13 @@
      * @return {!Promise<!Object>}
      */
     getAccountDetails(userId) {
-      return this.fetchJSON(`/accounts/${encodeURIComponent(userId)}/detail`);
+      return this._fetchJSON({
+        url: `/accounts/${encodeURIComponent(userId)}/detail`,
+      });
     },
 
     getAccountEmails() {
-      return this._fetchSharedCacheURL('/accounts/self/emails');
+      return this._fetchSharedCacheURL({url: '/accounts/self/emails'});
     },
 
     /**
@@ -692,15 +727,17 @@
     },
 
     getAccountStatus(userId) {
-      return this.fetchJSON(`/accounts/${encodeURIComponent(userId)}/status`);
+      return this._fetchJSON({
+        url: `/accounts/${encodeURIComponent(userId)}/status`,
+      });
     },
 
     getAccountGroups() {
-      return this.fetchJSON('/accounts/self/groups');
+      return this._fetchJSON({url: '/accounts/self/groups'});
     },
 
     getAccountAgreements() {
-      return this.fetchJSON('/accounts/self/agreements');
+      return this._fetchJSON({url: '/accounts/self/agreements'});
     },
 
     saveAccountAgreement(name) {
@@ -717,8 +754,9 @@
             .map(param => { return encodeURIComponent(param); })
             .join('&q=');
       }
-      return this._fetchSharedCacheURL('/accounts/self/capabilities' +
-          queryString);
+      return this._fetchSharedCacheURL({
+        url: '/accounts/self/capabilities' + queryString,
+      });
     },
 
     getLoggedIn() {
@@ -741,31 +779,31 @@
 
     checkCredentials() {
       // Skip the REST response cache.
-      return this._fetchRawJSON('/accounts/self/detail').then(response => {
-        if (!response) { return; }
-        if (response.status === 403) {
+      return this._fetchRawJSON({url: '/accounts/self/detail'}).then(res => {
+        if (!res) { return; }
+        if (res.status === 403) {
           this.fire('auth-error');
           this._cache['/accounts/self/detail'] = null;
-        } else if (response.ok) {
-          return this.getResponseObject(response);
+        } else if (res.ok) {
+          return this.getResponseObject(res);
         }
-      }).then(response => {
-        if (response) {
-          this._cache['/accounts/self/detail'] = response;
+      }).then(res => {
+        if (res) {
+          this._cache['/accounts/self/detail'] = res;
         }
-        return response;
+        return res;
       });
     },
 
     getDefaultPreferences() {
-      return this._fetchSharedCacheURL('/config/server/preferences');
+      return this._fetchSharedCacheURL({url: '/config/server/preferences'});
     },
 
     getPreferences() {
       return this.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
-          return this._fetchSharedCacheURL('/accounts/self/preferences').then(
-              res => {
+          return this._fetchSharedCacheURL({url: '/accounts/self/preferences'})
+              .then(res => {
                 if (this._isNarrowScreen()) {
                   res.default_diff_view = DiffViewMode.UNIFIED;
                 } else {
@@ -786,7 +824,9 @@
     },
 
     getWatchedProjects() {
-      return this._fetchSharedCacheURL('/accounts/self/watched.projects');
+      return this._fetchSharedCacheURL({
+        url: '/accounts/self/watched.projects',
+      });
     },
 
     /**
@@ -813,29 +853,28 @@
     },
 
     /**
-     * @param {string} url
-     * @param {function(?Response, string=)=} opt_errFn
+     * @param {Defs.FetchJSONRequest} req
      */
-    _fetchSharedCacheURL(url, opt_errFn) {
-      if (this._sharedFetchPromises[url]) {
-        return this._sharedFetchPromises[url];
+    _fetchSharedCacheURL(req) {
+      if (this._sharedFetchPromises[req.url]) {
+        return this._sharedFetchPromises[req.url];
       }
       // TODO(andybons): Periodic cache invalidation.
-      if (this._cache[url] !== undefined) {
-        return Promise.resolve(this._cache[url]);
+      if (this._cache[req.url] !== undefined) {
+        return Promise.resolve(this._cache[req.url]);
       }
-      this._sharedFetchPromises[url] = this.fetchJSON(url, opt_errFn)
+      this._sharedFetchPromises[req.url] = this._fetchJSON(req)
           .then(response => {
             if (response !== undefined) {
-              this._cache[url] = response;
+              this._cache[req.url] = response;
             }
-            this._sharedFetchPromises[url] = undefined;
+            this._sharedFetchPromises[req.url] = undefined;
             return response;
           }).catch(err => {
-            this._sharedFetchPromises[url] = undefined;
+            this._sharedFetchPromises[req.url] = undefined;
             throw err;
           });
-      return this._sharedFetchPromises[url];
+      return this._sharedFetchPromises[req.url];
     },
 
     _isNarrowScreen() {
@@ -848,8 +887,8 @@
      * @param {number|string=} opt_offset
      * @param {!Object=} opt_options
      * @return {?Array<!Object>|?Array<!Array<!Object>>} If opt_query is an
-     *     array, fetchJSON will return an array of arrays of changeInfos. If it
-     *     is unspecified or a string, fetchJSON will return an array of
+     *     array, _fetchJSON will return an array of arrays of changeInfos. If it
+     *     is unspecified or a string, _fetchJSON will return an array of
      *     changeInfos.
      */
     getChanges(opt_changesPerPage, opt_query, opt_offset, opt_options) {
@@ -874,7 +913,7 @@
           this._maybeInsertInLookup(change);
         }
       };
-      return this.fetchJSON('/changes/', null, null, params).then(response => {
+      return this._fetchJSON({url: '/changes/', params}).then(response => {
         // Response may be an array of changes OR an array of arrays of
         // changes.
         if (opt_query instanceof Array) {
@@ -959,43 +998,43 @@
      * @param {function(?Response, string=)=} opt_errFn
      * @param {function()=} opt_cancelCondition
      */
-    _getChangeDetail(changeNum, params, opt_errFn,
-        opt_cancelCondition) {
+    _getChangeDetail(changeNum, params, opt_errFn, opt_cancelCondition) {
       return this.getChangeActionURL(changeNum, null, '/detail').then(url => {
         const urlWithParams = this._urlWithParams(url, params);
-        return this._fetchRawJSON(
-            url,
-            opt_errFn,
-            opt_cancelCondition,
-            {O: params},
-            this._etags.getOptions(urlWithParams))
-            .then(response => {
-              if (response && response.status === 304) {
-                return Promise.resolve(this._parsePrefixedJSON(
-                    this._etags.getCachedPayload(urlWithParams)));
-              }
+        const req = {
+          url,
+          errFn: opt_errFn,
+          cancelCondition: opt_cancelCondition,
+          params: {O: params},
+          fetchOptions: this._etags.getOptions(urlWithParams),
+        };
+        return this._fetchRawJSON(req).then(response => {
+          if (response && response.status === 304) {
+            return Promise.resolve(this._parsePrefixedJSON(
+                this._etags.getCachedPayload(urlWithParams)));
+          }
 
-              if (response && !response.ok) {
-                if (opt_errFn) {
-                  opt_errFn.call(null, response);
-                } else {
-                  this.fire('server-error', {response});
-                }
-                return;
-              }
+          if (response && !response.ok) {
+            if (opt_errFn) {
+              opt_errFn.call(null, response);
+            } else {
+              this.fire('server-error', {response});
+            }
+            return;
+          }
 
-              const payloadPromise = response ?
-                  this._readResponsePayload(response) :
-                  Promise.resolve(null);
+          const payloadPromise = response ?
+              this._readResponsePayload(response) :
+              Promise.resolve(null);
 
-              return payloadPromise.then(payload => {
-                if (!payload) { return null; }
-                this._etags.collect(urlWithParams, response, payload.raw);
-                this._maybeInsertInLookup(payload.parsed);
+          return payloadPromise.then(payload => {
+            if (!payload) { return null; }
+            this._etags.collect(urlWithParams, response, payload.raw);
+            this._maybeInsertInLookup(payload.parsed);
 
-                return payload.parsed;
-              });
-            });
+            return payload.parsed;
+          });
+        });
       });
     },
 
@@ -1004,7 +1043,11 @@
      * @param {number|string} patchNum
      */
     getChangeCommitInfo(changeNum, patchNum) {
-      return this._getChangeURLAndFetch(changeNum, '/commit?links', patchNum);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/commit?links',
+        patchNum,
+      });
     },
 
     /**
@@ -1019,8 +1062,12 @@
       } else if (!this.patchNumEquals(patchRange.basePatchNum, 'PARENT')) {
         params = {base: patchRange.basePatchNum};
       }
-      return this._getChangeURLAndFetch(changeNum, '/files',
-          patchRange.patchNum, undefined, undefined, params);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/files',
+        patchNum: patchRange.patchNum,
+        params,
+      });
     },
 
     /**
@@ -1032,7 +1079,7 @@
       if (patchRange.basePatchNum !== 'PARENT') {
         endpoint += '&base=' + encodeURIComponent(patchRange.basePatchNum + '');
       }
-      return this._getChangeURLAndFetch(changeNum, endpoint);
+      return this._getChangeURLAndFetch({changeNum, endpoint});
     },
 
     /**
@@ -1042,8 +1089,11 @@
      * @return {!Promise<!Object>}
      */
     queryChangeFiles(changeNum, patchNum, query) {
-      return this._getChangeURLAndFetch(changeNum,
-          `/files?q=${encodeURIComponent(query)}`, patchNum);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: `/files?q=${encodeURIComponent(query)}`,
+        patchNum,
+      });
     },
 
     /**
@@ -1071,16 +1121,16 @@
     },
 
     getChangeRevisionActions(changeNum, patchNum) {
-      return this._getChangeURLAndFetch(changeNum, '/actions', patchNum)
-          .then(revisionActions => {
-            // The rebase button on change screen is always enabled.
-            if (revisionActions.rebase) {
-              revisionActions.rebase.rebaseOnCurrent =
-                  !!revisionActions.rebase.enabled;
-              revisionActions.rebase.enabled = true;
-            }
-            return revisionActions;
-          });
+      const req = {changeNum, endpoint: '/actions', patchNum};
+      return this._getChangeURLAndFetch(req).then(revisionActions => {
+        // The rebase button on change screen is always enabled.
+        if (revisionActions.rebase) {
+          revisionActions.rebase.rebaseOnCurrent =
+              !!revisionActions.rebase.enabled;
+          revisionActions.rebase.enabled = true;
+        }
+        return revisionActions;
+      });
     },
 
     /**
@@ -1091,15 +1141,19 @@
     getChangeSuggestedReviewers(changeNum, inputVal, opt_errFn) {
       const params = {n: 10};
       if (inputVal) { params.q = inputVal; }
-      return this._getChangeURLAndFetch(changeNum, '/suggest_reviewers', null,
-          opt_errFn, null, params);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/suggest_reviewers',
+        errFn: opt_errFn,
+        params,
+      });
     },
 
     /**
      * @param {number|string} changeNum
      */
     getChangeIncludedIn(changeNum) {
-      return this._getChangeURLAndFetch(changeNum, '/in', null);
+      return this._getChangeURLAndFetch({changeNum, endpoint: '/in'});
     },
 
     _computeFilter(filter) {
@@ -1122,10 +1176,10 @@
     getGroups(filter, groupsPerPage, opt_offset) {
       const offset = opt_offset || 0;
 
-      return this._fetchSharedCacheURL(
-          `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
-          this._computeFilter(filter)
-      );
+      return this._fetchSharedCacheURL({
+        url: `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
+            this._computeFilter(filter),
+      });
     },
 
     /**
@@ -1139,10 +1193,10 @@
 
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchSharedCacheURL(
-          `/projects/?d&n=${reposPerPage + 1}&S=${offset}` +
-          this._computeFilter(filter)
-      );
+      return this._fetchSharedCacheURL({
+        url: `/projects/?d&n=${reposPerPage + 1}&S=${offset}` +
+            this._computeFilter(filter),
+      });
     },
 
     setRepoHead(repo, ref) {
@@ -1162,15 +1216,13 @@
      */
     getRepoBranches(filter, repo, reposBranchesPerPage, opt_offset, opt_errFn) {
       const offset = opt_offset || 0;
-
+      const count = reposBranchesPerPage + 1;
+      filter = this._computeFilter(filter);
+      repo = encodeURIComponent(repo);
+      const url = `/projects/${repo}/branches?n=${count}&S=${offset}${filter}`;
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this.fetchJSON(
-          `/projects/${encodeURIComponent(repo)}/branches` +
-          `?n=${reposBranchesPerPage + 1}&S=${offset}` +
-          this._computeFilter(filter),
-          opt_errFn
-      );
+      return this._fetchJSON({url, errFn: opt_errFn});
     },
 
     /**
@@ -1183,15 +1235,14 @@
      */
     getRepoTags(filter, repo, reposTagsPerPage, opt_offset, opt_errFn) {
       const offset = opt_offset || 0;
-
+      const encodedRepo = encodeURIComponent(repo);
+      const n = reposTagsPerPage + 1;
+      const encodedFilter = this._computeFilter(filter);
+      const url = `/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` +
+          encodedFilter;
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this.fetchJSON(
-          `/projects/${encodeURIComponent(repo)}/tags` +
-          `?n=${reposTagsPerPage + 1}&S=${offset}` +
-          this._computeFilter(filter),
-          opt_errFn
-      );
+      return this._fetchJSON({url, errFn: opt_errFn});
     },
 
     /**
@@ -1203,21 +1254,19 @@
      */
     getPlugins(filter, pluginsPerPage, opt_offset, opt_errFn) {
       const offset = opt_offset || 0;
-
-      return this.fetchJSON(
-          `/plugins/?all&n=${pluginsPerPage + 1}&S=${offset}` +
-          this._computeFilter(filter),
-          opt_errFn
-      );
+      const encodedFilter = this._computeFilter(filter);
+      const n = pluginsPerPage + 1;
+      const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
+      return this._fetchJSON({url, errFn: opt_errFn});
     },
 
     getRepoAccessRights(repoName, opt_errFn) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this.fetchJSON(
-          `/projects/${encodeURIComponent(repoName)}/access`,
-          opt_errFn
-      );
+      return this._fetchJSON({
+        url: `/projects/${encodeURIComponent(repoName)}/access`,
+        errFn: opt_errFn,
+      });
     },
 
     setRepoAccessRights(repoName, repoInfo) {
@@ -1243,7 +1292,12 @@
     getSuggestedGroups(inputVal, opt_n, opt_errFn, opt_ctx) {
       const params = {s: inputVal};
       if (opt_n) { params.n = opt_n; }
-      return this.fetchJSON('/groups/', opt_errFn, opt_ctx, params);
+      return this._fetchJSON({
+        url: '/groups/',
+        errFn: opt_errFn,
+        cancelCondition: opt_ctx,
+        params,
+      });
     },
 
     /**
@@ -1259,7 +1313,12 @@
         type: 'ALL',
       };
       if (opt_n) { params.n = opt_n; }
-      return this.fetchJSON('/projects/', opt_errFn, opt_ctx, params);
+      return this._fetchJSON({
+        url: '/projects/',
+        errFn: opt_errFn,
+        cancelCondition: opt_ctx,
+        params,
+      });
     },
 
     /**
@@ -1274,7 +1333,12 @@
       }
       const params = {suggest: null, q: inputVal};
       if (opt_n) { params.n = opt_n; }
-      return this.fetchJSON('/accounts/', opt_errFn, opt_ctx, params);
+      return this._fetchJSON({
+        url: '/accounts/',
+        errFn: opt_errFn,
+        cancelCondition: opt_ctx,
+        params,
+      });
     },
 
     addChangeReviewer(changeNum, reviewerID) {
@@ -1305,11 +1369,18 @@
     },
 
     getRelatedChanges(changeNum, patchNum) {
-      return this._getChangeURLAndFetch(changeNum, '/related', patchNum);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/related',
+        patchNum,
+      });
     },
 
     getChangesSubmittedTogether(changeNum) {
-      return this._getChangeURLAndFetch(changeNum, '/submitted_together', null);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/submitted_together',
+      });
     },
 
     getChangeConflicts(changeNum) {
@@ -1321,7 +1392,7 @@
         O: options,
         q: 'status:open is:mergeable conflicts:' + changeNum,
       };
-      return this.fetchJSON('/changes/', null, null, params);
+      return this._fetchJSON({url: '/changes/', params});
     },
 
     getChangeCherryPicks(project, changeID, changeNum) {
@@ -1339,7 +1410,7 @@
         O: options,
         q: query,
       };
-      return this.fetchJSON('/changes/', null, null, params);
+      return this._fetchJSON({url: '/changes/', params});
     },
 
     getChangesWithSameTopic(topic) {
@@ -1353,11 +1424,15 @@
         O: options,
         q: 'status:open topic:' + topic,
       };
-      return this.fetchJSON('/changes/', null, null, params);
+      return this._fetchJSON({url: '/changes/', params});
     },
 
     getReviewedFiles(changeNum, patchNum) {
-      return this._getChangeURLAndFetch(changeNum, '/files?reviewed', patchNum);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/files?reviewed',
+        patchNum,
+      });
     },
 
     /**
@@ -1395,10 +1470,12 @@
     getChangeEdit(changeNum, opt_download_commands) {
       const params = opt_download_commands ? {'download-commands': true} : null;
       return this.getLoggedIn().then(loggedIn => {
-        return loggedIn ?
-            this._getChangeURLAndFetch(changeNum, '/edit/', null, null, null,
-                params) :
-            false;
+        if (!loggedIn) { return false; }
+        return this._getChangeURLAndFetch({
+          changeNum,
+          endpoint: '/edit/',
+          params,
+        });
       });
     },
 
@@ -1607,8 +1684,14 @@
       }
       const endpoint = `/files/${encodeURIComponent(path)}/diff`;
 
-      return this._getChangeURLAndFetch(changeNum, endpoint, patchNum,
-          opt_errFn, opt_cancelCondition, params);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint,
+        patchNum,
+        errFn: opt_errFn,
+        cancelCondition: opt_cancelCondition,
+        params,
+      });
     },
 
     /**
@@ -1695,7 +1778,11 @@
        * @return {!Promise<!Object>} Diff comments response.
        */
       const fetchComments = opt_patchNum => {
-        return this._getChangeURLAndFetch(changeNum, endpoint, opt_patchNum);
+        return this._getChangeURLAndFetch({
+          changeNum,
+          endpoint,
+          patchNum: opt_patchNum,
+        });
       };
 
       if (!opt_basePatchNum && !opt_patchNum && !opt_path) {
@@ -1809,9 +1896,10 @@
     },
 
     getCommitInfo(project, commit) {
-      return this.fetchJSON(
-          '/projects/' + encodeURIComponent(project) +
-          '/commits/' + encodeURIComponent(commit));
+      return this._fetchJSON({
+        url: '/projects/' + encodeURIComponent(project) +
+            '/commits/' + encodeURIComponent(commit),
+      });
     },
 
     _fetchB64File(url) {
@@ -1940,7 +2028,7 @@
     },
 
     getAccountSSHKeys() {
-      return this._fetchSharedCacheURL('/accounts/self/sshkeys');
+      return this._fetchSharedCacheURL({url: '/accounts/self/sshkeys'});
     },
 
     addAccountSSHKey(key) {
@@ -1963,7 +2051,7 @@
     },
 
     getAccountGPGKeys() {
-      return this.fetchJSON('/accounts/self/gpgkeys');
+      return this._fetchJSON({url: '/accounts/self/gpgkeys'});
     },
 
     addAccountGPGKey(key) {
@@ -2006,7 +2094,10 @@
     },
 
     getCapabilities(token, opt_errFn) {
-      return this.fetchJSON('/config/server/capabilities', opt_errFn);
+      return this._fetchJSON({
+        url: '/config/server/capabilities',
+        errFn: opt_errFn,
+      });
     },
 
     setAssignee(changeNum, assignee) {
@@ -2073,11 +2164,13 @@
      */
     getChange(changeNum, opt_errFn) {
       // Cannot use _changeBaseURL, as this function is used by _projectLookup.
-      return this.fetchJSON(`/changes/?q=change:${changeNum}`, opt_errFn)
-          .then(res => {
-            if (!res || !res.length) { return null; }
-            return res[0];
-          });
+      return this._fetchJSON({
+        url: `/changes/?q=change:${changeNum}`,
+        errFn: opt_errFn,
+      }).then(res => {
+        if (!res || !res.length) { return null; }
+        return res[0];
+      });
     },
 
     /**
@@ -2140,23 +2233,20 @@
       });
     },
 
-   /**
-    * Alias for _changeBaseURL.then(fetchJSON).
-    * @todo(beckysiegel) clean up comments
-    * @param {string|number} changeNum
-    * @param {string} endpoint
-    * @param {?string|number=} opt_patchNum gets passed as null.
-    * @param {?function(?Response, string=)=} opt_errFn gets passed as null.
-    * @param {?function()=} opt_cancelCondition gets passed as null.
-    * @param {?Object=} opt_params gets passed as null.
-    * @param {!Object=} opt_options
-    * @return {!Promise<!Object>}
-    */
-    _getChangeURLAndFetch(changeNum, endpoint, opt_patchNum, opt_errFn,
-        opt_cancelCondition, opt_params, opt_options) {
-      return this._changeBaseURL(changeNum, opt_patchNum).then(url => {
-        return this.fetchJSON(url + endpoint, opt_errFn, opt_cancelCondition,
-            opt_params, opt_options);
+    /**
+     * Alias for _changeBaseURL.then(_fetchJSON).
+     * @param {Defs.ChangeFetchRequest} req
+     * @return {!Promise<!Object>}
+     */
+    _getChangeURLAndFetch(req) {
+      return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
+        return this._fetchJSON({
+          url: url + req.endpoint,
+          errFn: req.errFn,
+          cancelCondition: req.cancelCondition,
+          params: req.params,
+          fetchOptions: req.fetchOptions,
+        });
       });
     },
 
@@ -2171,9 +2261,12 @@
      */
     getBlame(changeNum, patchNum, path, opt_base) {
       const encodedPath = encodeURIComponent(path);
-      return this._getChangeURLAndFetch(changeNum,
-          `/files/${encodedPath}/blame`, patchNum, undefined, undefined,
-          opt_base ? {base: 't'} : undefined);
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: `/files/${encodedPath}/blame`,
+        patchNum,
+        params: opt_base ? {base: 't'} : undefined,
+      });
     },
 
     /**
@@ -2217,7 +2310,7 @@
     getDashboard(project, dashboard, opt_errFn) {
       const url = '/projects/' + encodeURIComponent(project) + '/dashboards/' +
           encodeURIComponent(dashboard);
-      return this._fetchSharedCacheURL(url, opt_errFn);
+      return this._fetchSharedCacheURL({url, errFn: opt_errFn});
     },
 
     getMergeable(changeNum) {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index fb20da4..7e71efa 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -58,7 +58,7 @@
     });
 
     test('JSON prefix is properly removed', done => {
-      element.fetchJSON('/dummy/url').then(obj => {
+      element._fetchJSON('/dummy/url').then(obj => {
         assert.deepEqual(obj, {hello: 'bonjour'});
         done();
       });
@@ -66,7 +66,7 @@
 
     test('cached results', done => {
       let n = 0;
-      sandbox.stub(element, 'fetchJSON', () => {
+      sandbox.stub(element, '_fetchJSON', () => {
         return Promise.resolve(++n);
       });
       const promises = [];
@@ -86,7 +86,7 @@
     test('cached promise', done => {
       const promise = Promise.reject('foo');
       element._cache['/foo'] = promise;
-      element._fetchSharedCacheURL('/foo').catch(p => {
+      element._fetchSharedCacheURL({url: '/foo'}).catch(p => {
         assert.equal(p, 'foo');
         done();
       });
@@ -120,7 +120,8 @@
           cancel() { cancelCalled = true; },
         },
       }));
-      element.fetchJSON('/dummy/url', null, () => { return true; }).then(
+      const cancelCondition = () => { return true; };
+      element._fetchJSON({url: '/dummy/url', cancelCondition}).then(
           obj => {
             assert.isUndefined(obj);
             assert.isTrue(cancelCalled);
@@ -129,7 +130,7 @@
     });
 
     test('parent diff comments are properly grouped', done => {
-      sandbox.stub(element, 'fetchJSON', () => {
+      sandbox.stub(element, '_fetchJSON', () => {
         return Promise.resolve({
           '/COMMIT_MSG': [],
           'sieve.go': [
@@ -272,7 +273,8 @@
     test('differing patch diff comments are properly grouped', done => {
       sandbox.stub(element, 'getFromProjectLookup')
           .returns(Promise.resolve('test'));
-      sandbox.stub(element, 'fetchJSON', url => {
+      sandbox.stub(element, '_fetchJSON', request => {
+        const url = request.url;
         if (url === '/changes/test~42/revisions/1') {
           return Promise.resolve({
             '/COMMIT_MSG': [],
@@ -386,11 +388,11 @@
     });
 
     suite('rebase action', () => {
-      let resolveFetchJSON;
+      let resolve_fetchJSON;
       setup(() => {
-        sandbox.stub(element, 'fetchJSON').returns(
+        sandbox.stub(element, '_fetchJSON').returns(
             new Promise(resolve => {
-              resolveFetchJSON = resolve;
+              resolve_fetchJSON = resolve;
             }));
       });
 
@@ -401,7 +403,7 @@
               assert.isFalse(response.rebase.rebaseOnCurrent);
               done();
             });
-        resolveFetchJSON({rebase: {}});
+        resolve_fetchJSON({rebase: {}});
       });
 
       test('rebase on current', done => {
@@ -411,7 +413,7 @@
               assert.isTrue(response.rebase.rebaseOnCurrent);
               done();
             });
-        resolveFetchJSON({rebase: {enabled: true}});
+        resolve_fetchJSON({rebase: {enabled: true}});
       });
     });
 
@@ -423,7 +425,7 @@
         element.addEventListener('server-error', resolve);
       });
 
-      element.fetchJSON().then(response => {
+      element._fetchJSON({}).then(response => {
         assert.isUndefined(response);
         assert.isTrue(getResponseObjectStub.notCalled);
         serverErrorEventPromise.then(() => done());
@@ -444,7 +446,7 @@
       element.addEventListener('server-error', serverErrorStub);
       const authErrorStub = sandbox.stub();
       element.addEventListener('auth-error', authErrorStub);
-      element.fetchJSON('/bar').then(r => {
+      element._fetchJSON('/bar').then(r => {
         flush(() => {
           assert.isTrue(authErrorStub.called);
           assert.isFalse(serverErrorStub.called);
@@ -484,10 +486,10 @@
     });
 
     test('legacy n,z key in change url is replaced', () => {
-      const stub = sandbox.stub(element, 'fetchJSON')
+      const stub = sandbox.stub(element, '_fetchJSON')
           .returns(Promise.resolve([]));
       element.getChanges(1, null, 'n,z');
-      assert.equal(stub.args[0][3].S, 0);
+      assert.equal(stub.lastCall.args[0].params.S, 0);
     });
 
     test('saveDiffPreferences invalidates cache line', () => {
@@ -512,7 +514,7 @@
           });
 
           element._cache[cacheKey] = 'fake cache';
-          stub.callArg(1);
+          stub.lastCall.args[0].errFn();
         });
 
     test('getAccount does not add to the cache when resp.status is 403',
@@ -527,7 +529,7 @@
             done();
           });
           element._cache[cacheKey] = 'fake cache';
-          stub.callArgWith(1, {status: 403});
+          stub.lastCall.args[0].errFn({status: 403});
         });
 
     test('getAccount when resp is successful', done => {
@@ -541,7 +543,8 @@
         done();
       });
       element._cache[cacheKey] = 'fake cache';
-      stub.callArg(1, {});
+
+      stub.lastCall.args[0].errFn({});
     });
 
     const preferenceSetup = function(testJSON, loggedIn, smallScreen) {
@@ -872,66 +875,69 @@
       const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
           .returns(Promise.resolve());
       return element.queryChangeFiles('42', 'edit', 'test/path.js').then(() => {
-        assert.deepEqual(fetchStub.lastCall.args,
-            ['42', '/files?q=test%2Fpath.js', 'edit']);
+        assert.deepEqual(fetchStub.lastCall.args[0], {
+          changeNum: '42',
+          endpoint: '/files?q=test%2Fpath.js',
+          patchNum: 'edit',
+        });
       });
     });
 
     test('getRepos', () => {
       sandbox.stub(element, '_fetchSharedCacheURL');
       element.getRepos('test', 25);
-      assert.isTrue(element._fetchSharedCacheURL.lastCall
-          .calledWithExactly('/projects/?d&n=26&S=0&m=test'));
+      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+          '/projects/?d&n=26&S=0&m=test');
 
       element.getRepos(null, 25);
-      assert.isTrue(element._fetchSharedCacheURL.lastCall
-          .calledWithExactly('/projects/?d&n=26&S=0'));
+      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+          '/projects/?d&n=26&S=0');
 
       element.getRepos('test', 25, 25);
-      assert.isTrue(element._fetchSharedCacheURL.lastCall
-          .calledWithExactly('/projects/?d&n=26&S=25&m=test'));
+      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+          '/projects/?d&n=26&S=25&m=test');
     });
 
     test('getRepos filter', () => {
       sandbox.stub(element, '_fetchSharedCacheURL');
       element.getRepos('test/test/test', 25);
-      assert.isTrue(element._fetchSharedCacheURL.lastCall
-          .calledWithExactly('/projects/?d&n=26&S=0&m=test%2Ftest%2Ftest'));
+      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+          '/projects/?d&n=26&S=0&m=test%2Ftest%2Ftest');
     });
 
     test('getRepos filter regex', () => {
       sandbox.stub(element, '_fetchSharedCacheURL');
       element.getRepos('^test.*', 25);
-      assert.isTrue(element._fetchSharedCacheURL.lastCall
-          .calledWithExactly('/projects/?d&n=26&S=0&r=%5Etest.*'));
+      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+          '/projects/?d&n=26&S=0&r=%5Etest.*');
     });
 
     test('getGroups filter regex', () => {
       sandbox.stub(element, '_fetchSharedCacheURL');
       element.getGroups('^test.*', 25);
-      assert.isTrue(element._fetchSharedCacheURL.lastCall
-          .calledWithExactly('/groups/?n=26&S=0&r=%5Etest.*'));
+      assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+          '/groups/?n=26&S=0&r=%5Etest.*');
     });
 
     test('gerrit auth is used', () => {
       sandbox.stub(Gerrit.Auth, 'fetch').returns(Promise.resolve());
-      element.fetchJSON('foo');
+      element._fetchJSON('foo');
       assert(Gerrit.Auth.fetch.called);
     });
 
-    test('getSuggestedAccounts does not return fetchJSON', () => {
-      const fetchJSONSpy = sandbox.spy(element, 'fetchJSON');
+    test('getSuggestedAccounts does not return _fetchJSON', () => {
+      const _fetchJSONSpy = sandbox.spy(element, '_fetchJSON');
       return element.getSuggestedAccounts().then(accts => {
-        assert.isFalse(fetchJSONSpy.called);
+        assert.isFalse(_fetchJSONSpy.called);
         assert.equal(accts.length, 0);
       });
     });
 
-    test('fetchJSON gets called by getSuggestedAccounts', () => {
-      const fetchJSONStub = sandbox.stub(element, 'fetchJSON',
+    test('_fetchJSON gets called by getSuggestedAccounts', () => {
+      const _fetchJSONStub = sandbox.stub(element, '_fetchJSON',
           () => Promise.resolve());
       return element.getSuggestedAccounts('own').then(() => {
-        assert.deepEqual(fetchJSONStub.lastCall.args[3], {
+        assert.deepEqual(_fetchJSONStub.lastCall.args[0].params, {
           q: 'own',
           suggest: null,
         });
@@ -1064,7 +1070,7 @@
 
     suite('getChanges populates _projectLookup', () => {
       test('multiple queries', () => {
-        sandbox.stub(element, 'fetchJSON')
+        sandbox.stub(element, '_fetchJSON')
             .returns(Promise.resolve([
               [
                 {_number: 1, project: 'test'},
@@ -1073,7 +1079,7 @@
                 {_number: 3, project: 'test/test'},
               ],
             ]));
-        // When opt_query instanceof Array, fetchJSON returns
+        // When opt_query instanceof Array, _fetchJSON returns
         // Array<Array<Object>>.
         return element.getChanges(null, []).then(() => {
           assert.equal(Object.keys(element._projectLookup).length, 3);
@@ -1084,14 +1090,14 @@
       });
 
       test('no query', () => {
-        sandbox.stub(element, 'fetchJSON')
+        sandbox.stub(element, '_fetchJSON')
             .returns(Promise.resolve([
               {_number: 1, project: 'test'},
               {_number: 2, project: 'test'},
               {_number: 3, project: 'test/test'},
             ]));
 
-        // When opt_query !instanceof Array, fetchJSON returns
+        // When opt_query !instanceof Array, _fetchJSON returns
         // Array<Object>.
         return element.getChanges().then(() => {
           assert.equal(Object.keys(element._projectLookup).length, 3);
@@ -1104,10 +1110,12 @@
 
     test('_getChangeURLAndFetch', () => {
       element._projectLookup = {1: 'test'};
-      const fetchStub = sandbox.stub(element, 'fetchJSON')
+      const fetchStub = sandbox.stub(element, '_fetchJSON')
           .returns(Promise.resolve());
-      return element._getChangeURLAndFetch(1, '/test', 1).then(() => {
-        assert.isTrue(fetchStub.calledWith('/changes/test~1/revisions/1/test'));
+      const req = {changeNum: 1, endpoint: '/test', patchNum: 1};
+      return element._getChangeURLAndFetch(req).then(() => {
+        assert.equal(fetchStub.lastCall.args[0].url,
+            '/changes/test~1/revisions/1/test');
       });
     });
 
@@ -1170,8 +1178,8 @@
         const range = {basePatchNum: 'PARENT', patchNum: 2};
         return element.getChangeFiles(123, range).then(() => {
           assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[2], 2);
-          assert.isNotOk(fetchStub.lastCall.args[5]);
+          assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
+          assert.isNotOk(fetchStub.lastCall.args[0].params);
         });
       });
 
@@ -1181,10 +1189,10 @@
         const range = {basePatchNum: 4, patchNum: 5};
         return element.getChangeFiles(123, range).then(() => {
           assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[2], 5);
-          assert.isOk(fetchStub.lastCall.args[5]);
-          assert.equal(fetchStub.lastCall.args[5].base, 4);
-          assert.isNotOk(fetchStub.lastCall.args[5].parent);
+          assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+          assert.isOk(fetchStub.lastCall.args[0].params);
+          assert.equal(fetchStub.lastCall.args[0].params.base, 4);
+          assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
         });
       });
 
@@ -1194,10 +1202,10 @@
         const range = {basePatchNum: -3, patchNum: 5};
         return element.getChangeFiles(123, range).then(() => {
           assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[2], 5);
-          assert.isOk(fetchStub.lastCall.args[5]);
-          assert.isNotOk(fetchStub.lastCall.args[5].base);
-          assert.equal(fetchStub.lastCall.args[5].parent, 3);
+          assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+          assert.isOk(fetchStub.lastCall.args[0].params);
+          assert.isNotOk(fetchStub.lastCall.args[0].params.base);
+          assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
         });
       });
     });
@@ -1208,10 +1216,10 @@
             .returns(Promise.resolve());
         return element.getDiff(123, 'PARENT', 2, 'foo/bar.baz').then(() => {
           assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[2], 2);
-          assert.isOk(fetchStub.lastCall.args[5]);
-          assert.isNotOk(fetchStub.lastCall.args[5].parent);
-          assert.isNotOk(fetchStub.lastCall.args[5].base);
+          assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
+          assert.isOk(fetchStub.lastCall.args[0].params);
+          assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
+          assert.isNotOk(fetchStub.lastCall.args[0].params.base);
         });
       });
 
@@ -1220,10 +1228,10 @@
             .returns(Promise.resolve());
         return element.getDiff(123, 4, 5, 'foo/bar.baz').then(() => {
           assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[2], 5);
-          assert.isOk(fetchStub.lastCall.args[5]);
-          assert.isNotOk(fetchStub.lastCall.args[5].parent);
-          assert.equal(fetchStub.lastCall.args[5].base, 4);
+          assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+          assert.isOk(fetchStub.lastCall.args[0].params);
+          assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
+          assert.equal(fetchStub.lastCall.args[0].params.base, 4);
         });
       });
 
@@ -1232,10 +1240,10 @@
             .returns(Promise.resolve());
         return element.getDiff(123, -3, 5, 'foo/bar.baz').then(() => {
           assert.isTrue(fetchStub.calledOnce);
-          assert.equal(fetchStub.lastCall.args[2], 5);
-          assert.isOk(fetchStub.lastCall.args[5]);
-          assert.isNotOk(fetchStub.lastCall.args[5].base);
-          assert.equal(fetchStub.lastCall.args[5].parent, 3);
+          assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+          assert.isOk(fetchStub.lastCall.args[0].params);
+          assert.isNotOk(fetchStub.lastCall.args[0].params.base);
+          assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
         });
       });
     });
@@ -1245,7 +1253,7 @@
       element.getDashboard('gerrit/project', 'default:main');
       assert.isTrue(fetchStub.calledOnce);
       assert.equal(
-          fetchStub.lastCall.args[0],
+          fetchStub.lastCall.args[0].url,
           '/projects/gerrit%2Fproject/dashboards/default%3Amain');
     });
 
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.html b/polygerrit-ui/app/styles/gr-change-list-styles.html
index 4f92039..7379b9c 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.html
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.html
@@ -124,7 +124,7 @@
         vertical-align: middle;
       }
       .leftPadding {
-        width: 20px;
+        width: var(--default-horizontal-margin);
       }
       .star {
         width: 30px;
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.html b/polygerrit-ui/app/styles/gr-page-nav-styles.html
index 6eee5a8..49aa033 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.html
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.html
@@ -24,7 +24,7 @@
         border-bottom: 1px solid transparent;
         border-top: 1px solid transparent;
         display: block;
-        padding: 0 2em;
+        padding: 0 calc(var(--default-horizontal-margin) + 0.5em);
       }
       .navStyles li a {
         display: block;
@@ -33,13 +33,13 @@
         white-space: nowrap;
       }
       .navStyles .subsectionItem {
-        padding-left: 3em;
+        padding-left: calc(var(--default-horizontal-margin) + 1.5em);
       }
       .navStyles .hideSubsection {
         display: none;
       }
       .navStyles li.sectionTitle {
-        padding: 0 2em 0 1.5em;
+        padding: 0 2em 0 var(--default-horizontal-margin);
       }
       .navStyles li.sectionTitle:not(:first-child) {
         margin-top: 1em;
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 6cf674a..6a562fc 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -88,6 +88,7 @@
     'core/gr-error-manager/gr-error-manager_test.html',
     'core/gr-main-header/gr-main-header_test.html',
     'core/gr-navigation/gr-navigation_test.html',
+    'core/gr-reporting/gr-jank-detector_test.html',
     'core/gr-reporting/gr-reporting_test.html',
     'core/gr-router/gr-router_test.html',
     'core/gr-search-bar/gr-search-bar_test.html',
diff --git a/proto/cache.proto b/proto/cache.proto
index 4a84ab1..634b595 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -25,3 +25,23 @@
   bytes next = 2;
   string strategy_name = 3;
 }
+
+// Serialized form of
+// com.google.gerrit.server.change.MergeabilityCacheImpl.EntryKey.
+// Next ID: 5
+message MergeabilityKeyProto {
+  bytes commit = 1;
+  bytes into = 2;
+  string submit_type = 3;
+  string merge_strategy = 4;
+}
+
+// Serialized form of com.google.gerrit.extensions.auth.oauth.OAuthToken.
+// Next ID: 6
+message OAuthTokenProto {
+  string token = 1;
+  string secret = 2;
+  string raw = 3;
+  int64 expires_at = 4;
+  string provider_id = 5;
+}