Merge "Handle 0-length nodes while annotating diffs"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 452ef23..a9d922d 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -2766,6 +2766,22 @@
 If not set or set to a zero, defaults to the number of logical CPUs as returned
 by the JVM. If set to a negative value, defaults to a direct executor.
 
+[[index.change.indexMergeable]]index.change.indexMergeable::
++
+Specifies if `mergeable` should be index or not. Indexing this field enables
+queries that contain the mergeability operator (`is:mergeable`). If enabled,
+Gerrit will check if the change is mergeable into the target branch when
+reindexing a change. This is an expensive operation.
++
+If true, Gerrit will reindex all open changes when the target ref advances.
+Depending on the frequency of updates to the ref and the number of open changes,
+this can be very expensive.
++
+When this setting is changed from `false` to `true`, all changes need to be
+reindexed.
++
+Defaults to true.
+
 [[index.onlineUpgrade]]index.onlineUpgrade::
 +
 Whether to upgrade to new index schema versions while the server is
@@ -2813,19 +2829,6 @@
 +
 Defaults to 1024.
 
-[[index.reindexAfterRefUpdate]]index.reindexAfterRefUpdate::
-+
-Whether to reindex all affected open changes after a ref is updated. This
-includes reindexing all open changes to recompute the "mergeable" bit every time
-the destination branch moves, as well as reindexing changes to take into account
-new project configuration (e.g. label definitions).
-+
-Leaving this enabled may result in fresher results, but may cause performance
-problems if there are lots of open changes on a project whose branches advance
-frequently.
-+
-Defaults to true.
-
 [[index.autoReindexIfStale]]index.autoReindexIfStale::
 +
 Whether to automatically check if a document became stale in the index
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 18501b3..394886d 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -88,6 +88,8 @@
 * openid:xerces
 * polymer_externs:polymer_closure
 * blame-cache
+* caffeine
+* caffeine-guava
 * gson
 * guava
 * guava-failureaccess
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 69a7641..0fc733a 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -315,6 +315,11 @@
 the `mergeable` field will always be omitted and `SKIP_MERGEABLE` has no
 effect.
 +
+When link:config-gerrit.html#index.change.indexMergeable[
+`index.change.indexMergeable`] is set to `false` in the `gerrit.config`,
+the `mergeable` field will always be omitted when querying changes and
+`SKIP_MERGEABLE` has no effect.
++
 A change's mergeability can be requested separately by calling the
 link:#get-mergeable[get-mergeable] endpoint.
 --
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 74ec8d3..c373b96 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3233,6 +3233,66 @@
   HTTP/1.1 204 No Content
 ----
 
+[[batch-update-labels]]
+=== Batch Update Labels
+--
+'POST /projects/link:#project-name[\{project-name\}]/labels/'
+--
+
+Creates/updates/deletes multiple label definitions in this project at once.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+The updates must be specified in the request body as
+link:#batch-label-input[BatchLabelInput] entity.
+
+The updates are processed in the following order:
+
+1. label deletions
+2. label creations
+3. label updates
+
+.Request
+----
+  POST /projects/My-Project/labels/ HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Update Labels",
+    "delete": [
+      "Old-Review",
+      "Unused-Review"
+    ],
+    "create": [
+      {
+        "name": "Foo-Review",
+        "values": {
+          " 0": "No score",
+          "-1": "I would prefer this is not merged as is",
+          "-2": "This shall not be merged",
+          "+1": "Looks good to me, but someone else must approve",
+          "+2": "Looks good to me, approved"
+      }
+    ],
+    "update:" {
+      "Bar-Review": {
+        "function": "MaxWithBlock"
+      },
+      "Baz-Review": {
+        "copy_min_score": true
+      }
+    }
+  }
+----
+
+If the label updates were done successfully the response is "`200 OK`".
+
+.Response
+----
+  HTTP/1.1 200 OK
+----
+
 
 [[ids]]
 == IDs
@@ -3645,8 +3705,6 @@
 |`inherited_value` |optional|
 The inherited value of the configuration parameter, only set if
 `inheritable` is true.
-|`permitted_values` |optional|
-The list of permitted values, only set if the `type` is `LIST`.
 |===============================
 
 [[dashboard-info]]
@@ -3876,9 +3934,14 @@
 |Field Name      ||Description
 |`commit_message`|optional|
 Message that should be used to commit the change of the label in the
-`project.config` file to the `refs/meta/config` branch.
+`project.config` file to the `refs/meta/config` branch.+
+Must not be set if this `LabelDefinitionInput` entity is contained in a
+link:#batch-label-input[BatchLabelInput] entity.
 |`name`          |optional|
-The new link:config-labels.html#label_name[name] of the label.
+The new link:config-labels.html#label_name[name] of the label.+
+For label creation the name is required if this `LabelDefinitionInput` entity
+is contained in a link:#batch-label-input[BatchLabelInput]
+entity.
 |`function`      |optional|
 The new link:config-labels.html#label_function[function] of the label (can be
 `MaxWithBlock`, `AnyWithBlock`, `MaxNoBlock`, `NoBlock`, `NoOp` and `PatchSetLock`.
@@ -3961,6 +4024,27 @@
 Not set if not inherited or overridden.
 |===============================
 
+[[batch-label-input]]
+=== BatchLabelInput
+The `BatchLabelInput` entity contains information for batch updating label
+definitions in a project.
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name      ||Description
+|`commit_message`|optional|
+Message that should be used to commit the label updates in the
+`project.config` file to the `refs/meta/config` branch.
+|`delete`        |optional|
+List of labels that should be deleted.
+|`create`        |optional|
+List of link:#label-definition-input[LabelDefinitionInput] entities that
+describe labels that should be created.
+|`update`        |optional|
+Map of label names to link:#label-definition-input[LabelDefinitionInput]
+entities that describe the updates that should be done for the labels.
+|=============================
+
 [[project-access-input]]
 === ProjectAccessInput
 The `ProjectAccessInput` describes changes that should be applied to a project
diff --git a/WORKSPACE b/WORKSPACE
index 4e2c970..7529d8a 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -241,6 +241,31 @@
     sha1 = "1dcf1de382a0bf95a3d8b0849546c88bac1292c9",
 )
 
+CAFFEINE_VERS = "2.8.0"
+
+maven_jar(
+    name = "caffeine",
+    artifact = "com.github.ben-manes.caffeine:caffeine:" + CAFFEINE_VERS,
+    sha1 = "6000774d7f8412ced005a704188ced78beeed2bb",
+)
+
+# TODO(davido): Rename guava.jar to caffeine-guava.jar on fetch to prevent potential
+# naming collision between caffeine guava adapater and guava library itself.
+# Remove this renaming procedure, once this upstream issue is fixed:
+# https://github.com/ben-manes/caffeine/issues/364.
+http_file(
+    name = "caffeine-guava-renamed",
+    downloaded_file_path = "caffeine-guava-" + CAFFEINE_VERS + ".jar",
+    sha256 = "3a66ee3ec70971dee0bae6e56bda7b8742bc4bedd7489161bfbbaaf7137d89e1",
+    urls = [
+        "https://repo1.maven.org/maven2/com/github/ben-manes/caffeine/guava/" +
+        CAFFEINE_VERS +
+        "/guava-" +
+        CAFFEINE_VERS +
+        ".jar",
+    ],
+)
+
 maven_jar(
     name = "jsch",
     artifact = "com.jcraft:jsch:0.1.54",
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 6e736a0..af8af90 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -563,8 +563,8 @@
     cfg.setInt("sshd", null, "commandStartThreads", 1);
     cfg.setInt("receive", null, "threadPoolSize", 1);
     cfg.setInt("index", null, "threads", 1);
-    if (cfg.getString("index", null, "reindexAfterRefUpdate") == null) {
-      cfg.setBoolean("index", null, "reindexAfterRefUpdate", false);
+    if (cfg.getString("index", "change", "indexMergeable") == null) {
+      cfg.setBoolean("index", "change", "indexMergeable", false);
     }
   }
 
diff --git a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
index a06f90f..c3e3264 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
@@ -74,7 +75,7 @@
   public void replace(AccountState as) {
     BulkRequest bulk =
         new IndexRequest(getId(as), indexName, type, client.adapter())
-            .add(new UpdateRequest<>(schema, as));
+            .add(new UpdateRequest<>(schema, as, ImmutableSet.of()));
 
     String uri = getURI(type, BULK);
     Response response = postRequest(uri, bulk, getRefreshParam());
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index 37184cc..084c2ec 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
@@ -48,6 +49,7 @@
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -65,6 +67,7 @@
 import java.util.Optional;
 import java.util.Set;
 import org.apache.http.HttpStatus;
+import org.eclipse.jgit.lib.Config;
 import org.elasticsearch.client.Response;
 
 /** Secondary index implementation using Elasticsearch. */
@@ -91,6 +94,7 @@
   private final ChangeData.Factory changeDataFactory;
   private final Schema<ChangeData> schema;
   private final FieldDef<ChangeData, ?> idField;
+  private final ImmutableSet<String> skipFields;
 
   @Inject
   ElasticChangeIndex(
@@ -98,6 +102,7 @@
       ChangeData.Factory changeDataFactory,
       SitePaths sitePaths,
       ElasticRestClientProvider clientBuilder,
+      @GerritServerConfig Config gerritConfig,
       @Assisted Schema<ChangeData> schema) {
     super(cfg, sitePaths, schema, clientBuilder, CHANGES);
     this.changeDataFactory = changeDataFactory;
@@ -105,6 +110,10 @@
     this.mapping = new ChangeMapping(schema, client.adapter());
     this.idField =
         this.schema.useLegacyNumericFields() ? ChangeField.LEGACY_ID : ChangeField.LEGACY_ID_STR;
+    this.skipFields =
+        gerritConfig.getBoolean("index", "change", "indexMergeable", true)
+            ? ImmutableSet.of()
+            : ImmutableSet.of(ChangeField.MERGEABLE.getName());
   }
 
   @Override
@@ -123,7 +132,7 @@
     ElasticQueryAdapter adapter = client.adapter();
     BulkRequest bulk =
         new IndexRequest(getId(cd), indexName, adapter.getType(insertIndex), adapter)
-            .add(new UpdateRequest<>(schema, cd));
+            .add(new UpdateRequest<>(schema, cd, skipFields));
     if (adapter.deleteToReplace()) {
       bulk.add(new DeleteRequest(cd.getId().toString(), indexName, deleteIndex, adapter));
     }
@@ -263,7 +272,7 @@
 
     // Mergeable.
     JsonElement mergeableElement = source.get(ChangeField.MERGEABLE.getName());
-    if (mergeableElement != null) {
+    if (mergeableElement != null && !skipFields.contains(ChangeField.MERGEABLE.getName())) {
       String mergeable = mergeableElement.getAsString();
       if ("1".equals(mergeable)) {
         cd.setMergeable(true);
diff --git a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
index c215132..ce2025f 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
@@ -74,7 +75,7 @@
   public void replace(InternalGroup group) {
     BulkRequest bulk =
         new IndexRequest(getId(group), indexName, type, client.adapter())
-            .add(new UpdateRequest<>(schema, group));
+            .add(new UpdateRequest<>(schema, group, ImmutableSet.of()));
 
     String uri = getURI(type, BULK);
     Response response = postRequest(uri, bulk, getRefreshParam());
diff --git a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
index 29f8507..b636706 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
@@ -74,7 +75,7 @@
   public void replace(ProjectData projectState) {
     BulkRequest bulk =
         new IndexRequest(projectState.getProject().getName(), indexName, type, client.adapter())
-            .add(new UpdateRequest<>(schema, projectState));
+            .add(new UpdateRequest<>(schema, projectState, ImmutableSet.of()));
 
     String uri = getURI(type, BULK);
     Response response = postRequest(uri, bulk, getRefreshParam());
diff --git a/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java b/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
index 2f0bd01..196b8d6 100644
--- a/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
+++ b/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
@@ -16,6 +16,7 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.gerrit.elasticsearch.builders.XContentBuilder;
@@ -27,17 +28,19 @@
 
   private final Schema<V> schema;
   private final V v;
+  private final ImmutableSet<String> skipFields;
 
-  public UpdateRequest(Schema<V> schema, V v) {
+  public UpdateRequest(Schema<V> schema, V v, ImmutableSet<String> skipFields) {
     this.schema = schema;
     this.v = v;
+    this.skipFields = skipFields;
   }
 
   @Override
   protected String getRequest() {
     try (XContentBuilder closeable = new XContentBuilder()) {
       XContentBuilder builder = closeable.startObject();
-      for (Values<V> values : schema.buildFields(v)) {
+      for (Values<V> values : schema.buildFields(v, skipFields)) {
         String name = values.getField().getName();
         if (values.getField().isRepeatable()) {
           builder.field(name, Streams.stream(values.getValues()).collect(toList()));
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 6d02ec4..9873995 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.common.BatchLabelInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
@@ -221,6 +222,13 @@
   LabelApi label(String labelName) throws RestApiException;
 
   /**
+   * Adds, updates and deletes label definitions in a batch.
+   *
+   * @param input input that describes additions, updates and deletions of label definitions
+   */
+  void labels(BatchLabelInput input) throws RestApiException;
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
@@ -404,5 +412,10 @@
     public LabelApi label(String labelName) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void labels(BatchLabelInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/BatchLabelInput.java b/java/com/google/gerrit/extensions/common/BatchLabelInput.java
new file mode 100644
index 0000000..eb4c581
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/BatchLabelInput.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+import java.util.Map;
+
+/** Input for the REST API that describes additions, updates and deletions of label definitions. */
+public class BatchLabelInput {
+  public String commitMessage;
+  public List<String> delete;
+  public List<LabelDefinitionInput> create;
+  public Map<String, LabelDefinitionInput> update;
+}
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index f9f8c48..0aa374b 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -15,11 +15,12 @@
 package com.google.gerrit.index;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.base.MoreObjects;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -181,12 +182,17 @@
    * <p>Null values are omitted, as are fields which cause errors, which are logged.
    *
    * @param obj input object.
+   * @param skipFields set of field names to skip when indexing the document
    * @return all non-null field values from the object.
    */
-  public final Iterable<Values<T>> buildFields(T obj) {
-    return FluentIterable.from(fields.values())
-        .transform(
+  public final Iterable<Values<T>> buildFields(T obj, ImmutableSet<String> skipFields) {
+    return fields.values().stream()
+        .map(
             f -> {
+              if (skipFields.contains(f.getName())) {
+                return null;
+              }
+
               Object v;
               try {
                 v = f.get(obj);
@@ -203,7 +209,8 @@
                 return new Values<>(f, Collections.singleton(v));
               }
             })
-        .filter(Objects::nonNull);
+        .filter(Objects::nonNull)
+        .collect(toImmutableList());
   }
 
   @Override
diff --git a/java/com/google/gerrit/index/query/RangeUtil.java b/java/com/google/gerrit/index/query/RangeUtil.java
index 1f22f36..cfe1929 100644
--- a/java/com/google/gerrit/index/query/RangeUtil.java
+++ b/java/com/google/gerrit/index/query/RangeUtil.java
@@ -106,6 +106,10 @@
         break;
     }
 
+    // Ensure that minValue <= min/max <= maxValue.
+    min = Ints.constrainToRange(min, minValue, maxValue);
+    max = Ints.constrainToRange(max, minValue, maxValue);
+
     return new Range(prefix, min, max);
   }
 
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index deb3203..5392ab4 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -21,6 +21,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
@@ -98,6 +99,7 @@
   private final SitePaths sitePaths;
   private final Directory dir;
   private final String name;
+  private final ImmutableSet<String> skipFields;
   private final ListeningExecutorService writerThread;
   private final IndexWriter writer;
   private final ReferenceManager<IndexSearcher> searcherManager;
@@ -110,6 +112,7 @@
       SitePaths sitePaths,
       Directory dir,
       String name,
+      ImmutableSet<String> skipFields,
       String subIndex,
       GerritIndexWriterConfig writerConfig,
       SearcherFactory searcherFactory)
@@ -118,6 +121,7 @@
     this.sitePaths = sitePaths;
     this.dir = dir;
     this.name = name;
+    this.skipFields = skipFields;
     String index = Joiner.on('_').skipNulls().join(name, subIndex);
     long commitPeriod = writerConfig.getCommitWithinMs();
 
@@ -311,7 +315,7 @@
 
   Document toDocument(V obj) {
     Document result = new Document();
-    for (Values<V> vs : schema.buildFields(obj)) {
+    for (Values<V> vs : schema.buildFields(obj, skipFields)) {
       if (vs.getValues() != null) {
         add(result, vs);
       }
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index fd439f1..e51a91a7 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.lucene.LuceneChangeIndex.UPDATED_SORT_FIELD;
 import static com.google.gerrit.server.index.change.ChangeSchemaDefinitions.NAME;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
@@ -48,6 +49,7 @@
       Schema<ChangeData> schema,
       SitePaths sitePaths,
       Path path,
+      ImmutableSet<String> skipFields,
       GerritIndexWriterConfig writerConfig,
       SearcherFactory searcherFactory)
       throws IOException {
@@ -56,6 +58,7 @@
         sitePaths,
         FSDirectory.open(path),
         path.getFileName().toString(),
+        skipFields,
         writerConfig,
         searcherFactory);
   }
@@ -65,10 +68,11 @@
       SitePaths sitePaths,
       Directory dir,
       String subIndex,
+      ImmutableSet<String> skipFields,
       GerritIndexWriterConfig writerConfig,
       SearcherFactory searcherFactory)
       throws IOException {
-    super(schema, sitePaths, dir, NAME, subIndex, writerConfig, searcherFactory);
+    super(schema, sitePaths, dir, NAME, skipFields, subIndex, writerConfig, searcherFactory);
   }
 
   @Override
diff --git a/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index efd7ea3..242cffd 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.server.index.account.AccountField.ID_STR;
 import static com.google.gerrit.server.index.account.AccountField.PREFERRED_EMAIL_EXACT;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
@@ -100,6 +101,7 @@
         sitePaths,
         dir(schema, cfg, sitePaths),
         ACCOUNTS,
+        ImmutableSet.of(),
         null,
         new GerritIndexWriterConfig(cfg, ACCOUNTS),
         new SearcherFactory());
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 16d66b6..3b277dd 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -29,6 +29,7 @@
 import com.google.common.collect.Collections2;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
@@ -167,6 +168,7 @@
   private final String idSortFieldName;
   private final IdTerm idTerm;
   private final ChangeIdExtractor extractor;
+  private final ImmutableSet<String> skipFields;
 
   @Inject
   LuceneChangeIndex(
@@ -179,6 +181,10 @@
     this.executor = executor;
     this.changeDataFactory = changeDataFactory;
     this.schema = schema;
+    this.skipFields =
+        cfg.getBoolean("index", "change", "indexMergeable", true)
+            ? ImmutableSet.of()
+            : ImmutableSet.of(ChangeField.MERGEABLE.getName());
 
     GerritIndexWriterConfig openConfig = new GerritIndexWriterConfig(cfg, "changes_open");
     GerritIndexWriterConfig closedConfig = new GerritIndexWriterConfig(cfg, "changes_closed");
@@ -189,18 +195,40 @@
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
       openIndex =
           new ChangeSubIndex(
-              schema, sitePaths, new RAMDirectory(), "ramOpen", openConfig, searcherFactory);
+              schema,
+              sitePaths,
+              new RAMDirectory(),
+              "ramOpen",
+              skipFields,
+              openConfig,
+              searcherFactory);
       closedIndex =
           new ChangeSubIndex(
-              schema, sitePaths, new RAMDirectory(), "ramClosed", closedConfig, searcherFactory);
+              schema,
+              sitePaths,
+              new RAMDirectory(),
+              "ramClosed",
+              skipFields,
+              closedConfig,
+              searcherFactory);
     } else {
       Path dir = LuceneVersionManager.getDir(sitePaths, CHANGES, schema);
       openIndex =
           new ChangeSubIndex(
-              schema, sitePaths, dir.resolve(CHANGES_OPEN), openConfig, searcherFactory);
+              schema,
+              sitePaths,
+              dir.resolve(CHANGES_OPEN),
+              skipFields,
+              openConfig,
+              searcherFactory);
       closedIndex =
           new ChangeSubIndex(
-              schema, sitePaths, dir.resolve(CHANGES_CLOSED), closedConfig, searcherFactory);
+              schema,
+              sitePaths,
+              dir.resolve(CHANGES_CLOSED),
+              skipFields,
+              closedConfig,
+              searcherFactory);
     }
 
     idField = this.schema.useLegacyNumericFields() ? LEGACY_ID : LEGACY_ID_STR;
@@ -565,7 +593,7 @@
 
   private void decodeMergeable(ListMultimap<String, IndexableField> doc, ChangeData cd) {
     IndexableField f = Iterables.getFirst(doc.get(MERGEABLE_FIELD), null);
-    if (f != null) {
+    if (f != null && !skipFields.contains(MERGEABLE_FIELD)) {
       String mergeable = f.stringValue();
       if ("1".equals(mergeable)) {
         cd.setMergeable(true);
diff --git a/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index 99cd40d..3d1d471 100644
--- a/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.gerrit.server.index.group.GroupField.UUID;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
@@ -90,6 +91,7 @@
         sitePaths,
         dir(schema, cfg, sitePaths),
         GROUPS,
+        ImmutableSet.of(),
         null,
         new GerritIndexWriterConfig(cfg, GROUPS),
         new SearcherFactory());
diff --git a/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
index 97454c7..a3a0d9c 100644
--- a/java/com/google/gerrit/lucene/LuceneProjectIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.gerrit.index.project.ProjectField.NAME;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
@@ -90,6 +91,7 @@
         sitePaths,
         dir(schema, cfg, sitePaths),
         PROJECTS,
+        ImmutableSet.of(),
         null,
         new GerritIndexWriterConfig(cfg, PROJECTS),
         new SearcherFactory());
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index d7ab91b..6d7fc15 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.common.BatchLabelInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
@@ -77,6 +78,7 @@
 import com.google.gerrit.server.restapi.project.ListDashboards;
 import com.google.gerrit.server.restapi.project.ListLabels;
 import com.google.gerrit.server.restapi.project.ListTags;
+import com.google.gerrit.server.restapi.project.PostLabels;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.gerrit.server.restapi.project.PutConfig;
 import com.google.gerrit.server.restapi.project.PutDescription;
@@ -131,6 +133,7 @@
   private final Index index;
   private final IndexChanges indexChanges;
   private final Provider<ListLabels> listLabels;
+  private final PostLabels postLabels;
   private final LabelApiImpl.Factory labelApi;
 
   @AssistedInject
@@ -168,6 +171,7 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
       @Assisted ProjectResource project) {
     this(
@@ -205,6 +209,7 @@
         index,
         indexChanges,
         listLabels,
+        postLabels,
         labelApi,
         null);
   }
@@ -244,6 +249,7 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
       @Assisted String name) {
     this(
@@ -281,6 +287,7 @@
         index,
         indexChanges,
         listLabels,
+        postLabels,
         labelApi,
         name);
   }
@@ -320,6 +327,7 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
       String name) {
     this.permissionBackend = permissionBackend;
@@ -357,6 +365,7 @@
     this.index = index;
     this.indexChanges = indexChanges;
     this.listLabels = listLabels;
+    this.postLabels = postLabels;
     this.labelApi = labelApi;
   }
 
@@ -712,4 +721,13 @@
       throw asRestApiException("Cannot parse label", e);
     }
   }
+
+  @Override
+  public void labels(BatchLabelInput input) throws RestApiException {
+    try {
+      postLabels.apply(checkExists(), input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update labels", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/cache/CacheBackend.java b/java/com/google/gerrit/server/cache/CacheBackend.java
new file mode 100644
index 0000000..ec9876f
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/CacheBackend.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2019 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;
+
+/** Caffeine is used as default cache backend, but can be overridden with Guava backend. */
+public enum CacheBackend {
+  CAFFEINE,
+  GUAVA;
+
+  public boolean isLegacyBackend() {
+    return this == GUAVA;
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/CacheModule.java b/java/com/google/gerrit/server/cache/CacheModule.java
index 2878624..0fdc6f5 100644
--- a/java/com/google/gerrit/server/cache/CacheModule.java
+++ b/java/com/google/gerrit/server/cache/CacheModule.java
@@ -68,7 +68,8 @@
    */
   protected <K, V> CacheBinding<K, V> cache(
       String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
-    CacheProvider<K, V> m = new CacheProvider<>(this, name, keyType, valType);
+    CacheProvider<K, V> m =
+        new CacheProvider<>(this, name, keyType, valType, CacheBackend.CAFFEINE);
     bindCache(m, name, keyType, valType);
     return m;
   }
@@ -123,7 +124,20 @@
    */
   protected <K, V> PersistentCacheBinding<K, V> persist(
       String name, Class<K> keyType, Class<V> valType) {
-    return persist(name, TypeLiteral.get(keyType), TypeLiteral.get(valType));
+    return persist(name, TypeLiteral.get(keyType), TypeLiteral.get(valType), CacheBackend.CAFFEINE);
+  }
+
+  /**
+   * Declare a named in-memory/on-disk cache.
+   *
+   * @param <K> type of key used to lookup entries.
+   * @param <V> type of value stored by the cache.
+   * @param backend cache backend.
+   * @return binding to describe the cache.
+   */
+  protected <K, V> PersistentCacheBinding<K, V> persist(
+      String name, Class<K> keyType, Class<V> valType, CacheBackend backend) {
+    return persist(name, TypeLiteral.get(keyType), TypeLiteral.get(valType), backend);
   }
 
   /**
@@ -135,7 +149,7 @@
    */
   protected <K, V> PersistentCacheBinding<K, V> persist(
       String name, Class<K> keyType, TypeLiteral<V> valType) {
-    return persist(name, TypeLiteral.get(keyType), valType);
+    return persist(name, TypeLiteral.get(keyType), valType, CacheBackend.CAFFEINE);
   }
 
   /**
@@ -146,8 +160,9 @@
    * @return binding to describe the cache.
    */
   protected <K, V> PersistentCacheBinding<K, V> persist(
-      String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
-    PersistentCacheProvider<K, V> m = new PersistentCacheProvider<>(this, name, keyType, valType);
+      String name, TypeLiteral<K> keyType, TypeLiteral<V> valType, CacheBackend backend) {
+    PersistentCacheProvider<K, V> m =
+        new PersistentCacheProvider<>(this, name, keyType, valType, backend);
     bindCache(m, name, keyType, valType);
 
     Type cacheDefType =
diff --git a/java/com/google/gerrit/server/cache/CacheProvider.java b/java/com/google/gerrit/server/cache/CacheProvider.java
index b1a9b91..fe4244c 100644
--- a/java/com/google/gerrit/server/cache/CacheProvider.java
+++ b/java/com/google/gerrit/server/cache/CacheProvider.java
@@ -30,6 +30,7 @@
 
 class CacheProvider<K, V> implements Provider<Cache<K, V>>, CacheBinding<K, V>, CacheDef<K, V> {
   private final CacheModule module;
+  private final CacheBackend backend;
   final String name;
   private final TypeLiteral<K> keyType;
   private final TypeLiteral<V> valType;
@@ -44,11 +45,17 @@
   private MemoryCacheFactory memoryCacheFactory;
   private boolean frozen;
 
-  CacheProvider(CacheModule module, String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
+  CacheProvider(
+      CacheModule module,
+      String name,
+      TypeLiteral<K> keyType,
+      TypeLiteral<V> valType,
+      CacheBackend backend) {
     this.module = module;
     this.name = name;
     this.keyType = keyType;
     this.valType = valType;
+    this.backend = backend;
   }
 
   @Inject(optional = true)
@@ -159,7 +166,9 @@
   public Cache<K, V> get() {
     freeze();
     CacheLoader<K, V> ldr = loader();
-    return ldr != null ? memoryCacheFactory.build(this, ldr) : memoryCacheFactory.build(this);
+    return ldr != null
+        ? memoryCacheFactory.build(this, ldr, backend)
+        : memoryCacheFactory.build(this, backend);
   }
 
   protected void checkNotFrozen() {
diff --git a/java/com/google/gerrit/server/cache/MemoryCacheFactory.java b/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
index fc55753..558380d 100644
--- a/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
@@ -19,7 +19,8 @@
 import com.google.common.cache.LoadingCache;
 
 public interface MemoryCacheFactory {
-  <K, V> Cache<K, V> build(CacheDef<K, V> def);
+  <K, V> Cache<K, V> build(CacheDef<K, V> def, CacheBackend backend);
 
-  <K, V> LoadingCache<K, V> build(CacheDef<K, V> def, CacheLoader<K, V> loader);
+  <K, V> LoadingCache<K, V> build(
+      CacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend);
 }
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheFactory.java b/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
index 27fa9ca..93f91ef 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
@@ -19,9 +19,10 @@
 import com.google.common.cache.LoadingCache;
 
 public interface PersistentCacheFactory {
-  <K, V> Cache<K, V> build(PersistentCacheDef<K, V> def);
+  <K, V> Cache<K, V> build(PersistentCacheDef<K, V> def, CacheBackend backend);
 
-  <K, V> LoadingCache<K, V> build(PersistentCacheDef<K, V> def, CacheLoader<K, V> loader);
+  <K, V> LoadingCache<K, V> build(
+      PersistentCacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend);
 
   void onStop(String plugin);
 }
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheProvider.java b/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
index 59d66e3..4fc107f 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
@@ -30,6 +30,7 @@
 
 class PersistentCacheProvider<K, V> extends CacheProvider<K, V>
     implements Provider<Cache<K, V>>, PersistentCacheBinding<K, V>, PersistentCacheDef<K, V> {
+  private final CacheBackend backend;
   private int version;
   private long diskLimit;
   private CacheSerializer<K> keySerializer;
@@ -39,9 +40,19 @@
 
   PersistentCacheProvider(
       CacheModule module, String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
-    super(module, name, keyType, valType);
+    this(module, name, keyType, valType, CacheBackend.CAFFEINE);
+  }
+
+  PersistentCacheProvider(
+      CacheModule module,
+      String name,
+      TypeLiteral<K> keyType,
+      TypeLiteral<V> valType,
+      CacheBackend backend) {
+    super(module, name, keyType, valType, backend);
     version = -1;
     diskLimit = 128 << 20;
+    this.backend = backend;
   }
 
   @Inject(optional = true)
@@ -130,8 +141,8 @@
     freeze();
     CacheLoader<K, V> ldr = loader();
     return ldr != null
-        ? persistentCacheFactory.build(this, ldr)
-        : persistentCacheFactory.build(this);
+        ? persistentCacheFactory.build(this, ldr, backend)
+        : persistentCacheFactory.build(this, backend);
   }
 
   private static <T> void checkSerializer(
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index af1228d..2b068aa 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -21,6 +21,7 @@
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.MemoryCacheFactory;
 import com.google.gerrit.server.cache.PersistentCacheDef;
 import com.google.gerrit.server.cache.PersistentCacheFactory;
@@ -156,18 +157,21 @@
 
   @SuppressWarnings({"unchecked"})
   @Override
-  public <K, V> Cache<K, V> build(PersistentCacheDef<K, V> in) {
+  public <K, V> Cache<K, V> build(PersistentCacheDef<K, V> in, CacheBackend backend) {
     long limit = config.getLong("cache", in.configKey(), "diskLimit", in.diskLimit());
 
     if (cacheDir == null || limit <= 0) {
-      return memCacheFactory.build(in);
+      return memCacheFactory.build(in, backend);
     }
 
     H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
     SqlStore<K, V> store = newSqlStore(def, limit);
     H2CacheImpl<K, V> cache =
         new H2CacheImpl<>(
-            executor, store, def.keyType(), (Cache<K, ValueHolder<V>>) memCacheFactory.build(def));
+            executor,
+            store,
+            def.keyType(),
+            (Cache<K, ValueHolder<V>>) memCacheFactory.build(def, backend));
     synchronized (caches) {
       caches.add(cache);
     }
@@ -176,11 +180,12 @@
 
   @SuppressWarnings("unchecked")
   @Override
-  public <K, V> LoadingCache<K, V> build(PersistentCacheDef<K, V> in, CacheLoader<K, V> loader) {
+  public <K, V> LoadingCache<K, V> build(
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, CacheBackend backend) {
     long limit = config.getLong("cache", in.configKey(), "diskLimit", in.diskLimit());
 
     if (cacheDir == null || limit <= 0) {
-      return memCacheFactory.build(in, loader);
+      return memCacheFactory.build(in, loader, backend);
     }
 
     H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
@@ -188,7 +193,9 @@
     Cache<K, ValueHolder<V>> mem =
         (Cache<K, ValueHolder<V>>)
             memCacheFactory.build(
-                def, (CacheLoader<K, V>) new H2CacheImpl.Loader<>(executor, store, loader));
+                def,
+                (CacheLoader<K, V>) new H2CacheImpl.Loader<>(executor, store, loader),
+                backend);
     H2CacheImpl<K, V> cache = new H2CacheImpl<>(executor, store, def.keyType(), mem);
     synchronized (caches) {
       caches.add(cache);
diff --git a/java/com/google/gerrit/server/cache/mem/BUILD b/java/com/google/gerrit/server/cache/mem/BUILD
index d805e1f..a666df7 100644
--- a/java/com/google/gerrit/server/cache/mem/BUILD
+++ b/java/com/google/gerrit/server/cache/mem/BUILD
@@ -8,6 +8,8 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
+        "//lib:caffeine",
+        "//lib:caffeine-guava",
         "//lib:guava",
         "//lib:jgit",
         "//lib/guice",
diff --git a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
index f76b8db..9906b3d 100644
--- a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
@@ -17,13 +17,18 @@
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.RemovalListener;
+import com.github.benmanes.caffeine.cache.Weigher;
+import com.github.benmanes.caffeine.guava.CaffeinatedGuava;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.common.cache.Weigher;
+import com.google.common.cache.RemovalNotification;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.CacheDef;
 import com.google.gerrit.server.cache.ForwardingRemovalListener;
 import com.google.gerrit.server.cache.MemoryCacheFactory;
@@ -46,25 +51,30 @@
   }
 
   @Override
-  public <K, V> Cache<K, V> build(CacheDef<K, V> def) {
-    return create(def).build();
+  public <K, V> Cache<K, V> build(CacheDef<K, V> def, CacheBackend backend) {
+    return backend.isLegacyBackend()
+        ? createLegacy(def).build()
+        : CaffeinatedGuava.build(create(def));
   }
 
   @Override
-  public <K, V> LoadingCache<K, V> build(CacheDef<K, V> def, CacheLoader<K, V> loader) {
-    return create(def).build(loader);
+  public <K, V> LoadingCache<K, V> build(
+      CacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend) {
+    return backend.isLegacyBackend()
+        ? createLegacy(def).build(loader)
+        : CaffeinatedGuava.build(create(def), loader);
   }
 
   @SuppressWarnings("unchecked")
-  private <K, V> CacheBuilder<K, V> create(CacheDef<K, V> def) {
-    CacheBuilder<K, V> builder = newCacheBuilder();
+  private <K, V> CacheBuilder<K, V> createLegacy(CacheDef<K, V> def) {
+    CacheBuilder<K, V> builder = newLegacyCacheBuilder();
     builder.recordStats();
     builder.maximumWeight(
         cfg.getLong("cache", def.configKey(), "memoryLimit", def.maximumWeight()));
 
     builder = builder.removalListener(forwardingRemovalListenerFactory.create(def.name()));
 
-    Weigher<K, V> weigher = def.weigher();
+    com.google.common.cache.Weigher<K, V> weigher = def.weigher();
     if (weigher == null) {
       weigher = unitWeight();
     }
@@ -98,6 +108,42 @@
     return builder;
   }
 
+  private <K, V> Caffeine<K, V> create(CacheDef<K, V> def) {
+    Caffeine<K, V> builder = newCacheBuilder();
+    builder.recordStats();
+    builder.maximumWeight(
+        cfg.getLong("cache", def.configKey(), "memoryLimit", def.maximumWeight()));
+    builder = builder.removalListener(newRemovalListener(def.name()));
+    builder.weigher(newWeigher(def.weigher()));
+
+    Duration expireAfterWrite = def.expireAfterWrite();
+    if (has(def.configKey(), "maxAge")) {
+      builder.expireAfterWrite(
+          ConfigUtil.getTimeUnit(
+              cfg, "cache", def.configKey(), "maxAge", toSeconds(expireAfterWrite), SECONDS),
+          SECONDS);
+    } else if (expireAfterWrite != null) {
+      builder.expireAfterWrite(expireAfterWrite.toNanos(), NANOSECONDS);
+    }
+
+    Duration expireAfterAccess = def.expireFromMemoryAfterAccess();
+    if (has(def.configKey(), "expireFromMemoryAfterAccess")) {
+      builder.expireAfterAccess(
+          ConfigUtil.getTimeUnit(
+              cfg,
+              "cache",
+              def.configKey(),
+              "expireFromMemoryAfterAccess",
+              toSeconds(expireAfterAccess),
+              SECONDS),
+          SECONDS);
+    } else if (expireAfterAccess != null) {
+      builder.expireAfterAccess(expireAfterAccess.toNanos(), NANOSECONDS);
+    }
+
+    return builder;
+  }
+
   private static long toSeconds(@Nullable Duration duration) {
     return duration != null ? duration.getSeconds() : 0;
   }
@@ -107,11 +153,31 @@
   }
 
   @SuppressWarnings("unchecked")
-  private static <K, V> CacheBuilder<K, V> newCacheBuilder() {
+  private static <K, V> CacheBuilder<K, V> newLegacyCacheBuilder() {
     return (CacheBuilder<K, V>) CacheBuilder.newBuilder();
   }
 
-  private static <K, V> Weigher<K, V> unitWeight() {
+  private static <K, V> com.google.common.cache.Weigher<K, V> unitWeight() {
     return (key, value) -> 1;
   }
+
+  @SuppressWarnings("unchecked")
+  private static <K, V> Caffeine<K, V> newCacheBuilder() {
+    return (Caffeine<K, V>) Caffeine.newBuilder();
+  }
+
+  @SuppressWarnings("unchecked")
+  private <V, K> RemovalListener<K, V> newRemovalListener(String cacheName) {
+    return (k, v, cause) ->
+        forwardingRemovalListenerFactory
+            .create(cacheName)
+            .onRemoval(
+                RemovalNotification.create(
+                    k, v, com.google.common.cache.RemovalCause.valueOf(cause.name())));
+  }
+
+  private static <K, V> Weigher<K, V> newWeigher(
+      com.google.common.cache.Weigher<K, V> guavaWeigher) {
+    return guavaWeigher == null ? Weigher.singletonWeigher() : (k, v) -> guavaWeigher.weigh(k, v);
+  }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 9937fd0..21ee28a 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -138,6 +138,7 @@
           COMMIT_FOOTERS,
           CURRENT_ACTIONS,
           CURRENT_COMMIT,
+          DETAILED_LABELS, // may need to load ChangeNotes to check remove reviewer permissions
           MESSAGES);
 
   @Singleton
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index 7038736..7767fe2 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -310,7 +310,8 @@
 
     allRefsWatcher = new AllRefsWatcher();
     receivePack.setAdvertiseRefsHook(
-        ReceiveCommitsAdvertiseRefsHookChain.create(allRefsWatcher, queryProvider, projectName));
+        ReceiveCommitsAdvertiseRefsHookChain.create(
+            allRefsWatcher, queryProvider, projectName, user.getAccountId()));
     resultChangeIds = new ResultChangeIds();
     receiveCommits =
         factory.create(
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index 2b04d4d..7402a37 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -15,6 +15,7 @@
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/logging",
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
index 83bf554..6c1f097 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
@@ -18,14 +18,19 @@
 
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.git.HookUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeStatusPredicate;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.query.change.OwnerPredicate;
+import com.google.gerrit.server.query.change.ProjectPredicate;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -65,11 +70,13 @@
 
   private final Provider<InternalChangeQuery> queryProvider;
   private final Project.NameKey projectName;
+  private final Account.Id user;
 
   public ReceiveCommitsAdvertiseRefsHook(
-      Provider<InternalChangeQuery> queryProvider, Project.NameKey projectName) {
+      Provider<InternalChangeQuery> queryProvider, Project.NameKey projectName, Account.Id user) {
     this.queryProvider = queryProvider;
     this.projectName = projectName;
+    this.user = user;
   }
 
   @Override
@@ -90,7 +97,9 @@
 
   private Set<ObjectId> advertiseOpenChanges(Repository repo)
       throws ServiceMayNotContinueException {
-    // Advertise some recent open changes, in case a commit is based on one.
+    // Advertise the user's most recent open changes. It's likely that the user has one of these in
+    // their local repo and they can serve as starting points to figure out the common ancestor of
+    // what the client and server have in common.
     int limit = 32;
     try {
       Set<ObjectId> r = Sets.newHashSetWithExpectedSize(limit);
@@ -105,7 +114,11 @@
                   ChangeField.PATCH_SET)
               .enforceVisibility(true)
               .setLimit(limit)
-              .byProjectOpen(projectName)) {
+              .query(
+                  Predicate.and(
+                      new ProjectPredicate(projectName.get()),
+                      ChangeStatusPredicate.open(),
+                      new OwnerPredicate(user)))) {
         PatchSet ps = cd.currentPatchSet();
         if (ps != null) {
           // Ensure we actually observed a patch set ref pointing to this
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHookChain.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHookChain.java
index 76f6b04..fae1401 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHookChain.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHookChain.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git.receive;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Provider;
@@ -35,8 +36,9 @@
   public static AdvertiseRefsHook create(
       AllRefsWatcher allRefsWatcher,
       Provider<InternalChangeQuery> queryProvider,
-      Project.NameKey projectName) {
-    return create(allRefsWatcher, queryProvider, projectName, false);
+      Project.NameKey projectName,
+      Account.Id user) {
+    return create(allRefsWatcher, queryProvider, projectName, user, false);
   }
 
   /**
@@ -47,18 +49,19 @@
    */
   @VisibleForTesting
   public static AdvertiseRefsHook createForTest(
-      Provider<InternalChangeQuery> queryProvider, Project.NameKey projectName) {
-    return create(new AllRefsWatcher(), queryProvider, projectName, true);
+      Provider<InternalChangeQuery> queryProvider, Project.NameKey projectName, Account.Id user) {
+    return create(new AllRefsWatcher(), queryProvider, projectName, user, true);
   }
 
   private static AdvertiseRefsHook create(
       AllRefsWatcher allRefsWatcher,
       Provider<InternalChangeQuery> queryProvider,
       Project.NameKey projectName,
+      Account.Id user,
       boolean skipHackPushNegotiateHook) {
     List<AdvertiseRefsHook> advHooks = new ArrayList<>();
     advHooks.add(allRefsWatcher);
-    advHooks.add(new ReceiveCommitsAdvertiseRefsHook(queryProvider, projectName));
+    advHooks.add(new ReceiveCommitsAdvertiseRefsHook(queryProvider, projectName, user));
     if (!skipHackPushNegotiateHook) {
       advHooks.add(new HackPushNegotiateHook());
     }
diff --git a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index f6d3b6f..5efa065 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -76,7 +76,7 @@
     this.accountCache = accountCache;
     this.indexer = indexer;
     this.executor = executor;
-    this.enabled = cfg.getBoolean("index", null, "reindexAfterRefUpdate", true);
+    this.enabled = cfg.getBoolean("index", "change", "indexMergeable", true);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 1c0f298..7ca0b86 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -233,20 +233,13 @@
       return NO_OP_UPDATE;
     }
 
-    // If we touched every revision and there are no comments left, tell the
+    // If there are no comments left, tell the
     // caller to delete the entire ref.
-    boolean touchedAllRevs = updatedCommits.equals(rnm.revisionNotes.keySet());
-    if (touchedAllRevs && !hasComments) {
+    if (!rnm.noteMap.iterator().hasNext()) {
       return null;
     }
 
     ObjectId treeId = rnm.noteMap.writeTree(ins);
-    if (!rnm.noteMap.iterator().hasNext()) {
-      logger.atSevere().log(
-          "building draft update without content: hasComments=%s "
-              + "touchedAllRevs=%s updateCommits=%d revisionNotes=%d",
-          hasComments, touchedAllRevs, updatedCommits.size(), rnm.revisionNotes.size());
-    }
     cb.setTreeId(treeId);
     return cb;
   }
diff --git a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
index 6871652..15fa0f4 100644
--- a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
@@ -45,7 +46,9 @@
       @Override
       protected void configure() {
         factory(PatchListLoader.Factory.class);
-        persist(FILE_NAME, PatchListKey.class, PatchList.class)
+        // TODO(davido): Switch off using legacy cache backend, after fixing PatchListLoader
+        // to be recursion free.
+        persist(FILE_NAME, PatchListKey.class, PatchList.class, CacheBackend.GUAVA)
             .maximumWeight(10 << 20)
             .weigher(PatchListWeigher.class);
 
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 06fe471..378a512 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -440,8 +440,7 @@
     @Override
     public ForChange change(ChangeData cd) {
       try {
-        // TODO(hiesel) Force callers to call database() and use db instead of cd.db()
-        return getProjectControl().controlFor(cd.change()).asForChange(cd);
+        return getProjectControl().controlFor(cd.notes()).asForChange(cd);
       } catch (StorageException e) {
         return FailedPermissionBackend.change("unavailable", e);
       }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index c6beac4..78ca0fc 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -598,7 +598,11 @@
       committer = c.getCommitterIdent();
       parentCount = c.getParentCount();
     } catch (IOException e) {
-      throw new StorageException(e);
+      throw new StorageException(
+          String.format(
+              "Loading commit %s for ps %d of change %d failed.",
+              ps.commitId(), ps.id().get(), ps.id().changeId().get()),
+          e);
     }
     return true;
   }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index c86bd94..61b90f1 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -63,6 +63,7 @@
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.OperatorAliasConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -94,6 +95,7 @@
 import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 
 /** Parses a query string meant to be applied to change objects. */
@@ -221,6 +223,7 @@
     final GroupMembers groupMembers;
     final Provider<AnonymousUser> anonymousUserProvider;
     final OperatorAliasConfig operatorAliasConfig;
+    final boolean indexMergeable;
 
     private final Provider<CurrentUser> self;
 
@@ -253,7 +256,8 @@
         AccountCache accountCache,
         GroupMembers groupMembers,
         Provider<AnonymousUser> anonymousUserProvider,
-        OperatorAliasConfig operatorAliasConfig) {
+        OperatorAliasConfig operatorAliasConfig,
+        @GerritServerConfig Config gerritConfig) {
       this(
           queryProvider,
           rewriter,
@@ -281,7 +285,8 @@
           accountCache,
           groupMembers,
           anonymousUserProvider,
-          operatorAliasConfig);
+          operatorAliasConfig,
+          gerritConfig.getBoolean("index", "change", "indexMergeable", true));
     }
 
     private Arguments(
@@ -311,7 +316,8 @@
         AccountCache accountCache,
         GroupMembers groupMembers,
         Provider<AnonymousUser> anonymousUserProvider,
-        OperatorAliasConfig operatorAliasConfig) {
+        OperatorAliasConfig operatorAliasConfig,
+        boolean indexMergeable) {
       this.queryProvider = queryProvider;
       this.rewriter = rewriter;
       this.opFactories = opFactories;
@@ -339,6 +345,7 @@
       this.groupMembers = groupMembers;
       this.anonymousUserProvider = anonymousUserProvider;
       this.operatorAliasConfig = operatorAliasConfig;
+      this.indexMergeable = indexMergeable;
     }
 
     Arguments asUser(CurrentUser otherUser) {
@@ -369,7 +376,8 @@
           accountCache,
           groupMembers,
           anonymousUserProvider,
-          operatorAliasConfig);
+          operatorAliasConfig,
+          indexMergeable);
     }
 
     Arguments asUser(Account.Id otherId) {
@@ -570,6 +578,9 @@
     }
 
     if ("mergeable".equalsIgnoreCase(value)) {
+      if (!args.indexMergeable) {
+        throw new QueryParseException("server does not support 'mergeable'. check configs");
+      }
       return new BooleanPredicate(ChangeField.MERGEABLE);
     }
 
diff --git a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
index b92a464..65a7f4f 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.restapi.group;
 
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.NeedsParams;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -30,21 +33,27 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
-public class GroupsCollection implements RestCollection<TopLevelResource, GroupResource> {
+public class GroupsCollection
+    implements RestCollection<TopLevelResource, GroupResource>, NeedsParams {
   private final DynamicMap<RestView<GroupResource>> views;
+  private final Provider<ListGroups> list;
   private final Provider<QueryGroups> queryGroups;
   private final GroupControl.Factory groupControlFactory;
   private final GroupResolver groupResolver;
   private final Provider<CurrentUser> self;
 
+  private boolean hasQuery;
+
   @Inject
   public GroupsCollection(
       DynamicMap<RestView<GroupResource>> views,
+      Provider<ListGroups> list,
       Provider<QueryGroups> queryGroups,
       GroupControl.Factory groupControlFactory,
       GroupResolver groupResolver,
       Provider<CurrentUser> self) {
     this.views = views;
+    this.list = list;
     this.queryGroups = queryGroups;
     this.groupControlFactory = groupControlFactory;
     this.groupResolver = groupResolver;
@@ -52,6 +61,11 @@
   }
 
   @Override
+  public void setParams(ListMultimap<String, String> params) throws BadRequestException {
+    this.hasQuery = params.containsKey("query");
+  }
+
+  @Override
   public RestView<TopLevelResource> list() throws ResourceNotFoundException, AuthException {
     final CurrentUser user = self.get();
     if (user instanceof AnonymousUser) {
@@ -59,7 +73,12 @@
     } else if (!(user.isIdentifiedUser())) {
       throw new ResourceNotFoundException();
     }
-    return queryGroups.get();
+
+    if (hasQuery) {
+      return queryGroups.get();
+    }
+
+    return list.get();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index 03b9452..5d51527 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -91,85 +91,7 @@
     try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
       ProjectConfig config = projectConfigFactory.read(md);
 
-      if (config.getLabelSections().containsKey(id.get())) {
-        throw new ResourceConflictException(String.format("label %s already exists", id.get()));
-      }
-
-      for (String labelName : config.getLabelSections().keySet()) {
-        if (labelName.equalsIgnoreCase(id.get())) {
-          throw new ResourceConflictException(
-              String.format("label %s conflicts with existing label %s", id.get(), labelName));
-        }
-      }
-
-      if (input.values == null || input.values.isEmpty()) {
-        throw new BadRequestException("values are required");
-      }
-
-      List<LabelValue> values = LabelDefinitionInputParser.parseValues(input.values);
-
-      LabelType labelType;
-      try {
-        labelType = new LabelType(id.get(), values);
-      } catch (IllegalArgumentException e) {
-        throw new BadRequestException("invalid name: " + id.get(), e);
-      }
-
-      if (input.function != null && !input.function.trim().isEmpty()) {
-        labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
-      } else {
-        labelType.setFunction(LabelFunction.MAX_WITH_BLOCK);
-      }
-
-      if (input.defaultValue != null) {
-        labelType.setDefaultValue(
-            LabelDefinitionInputParser.parseDefaultValue(labelType, input.defaultValue));
-      }
-
-      if (input.branches != null) {
-        labelType.setRefPatterns(LabelDefinitionInputParser.parseBranches(input.branches));
-      }
-
-      if (input.canOverride != null) {
-        labelType.setCanOverride(input.canOverride);
-      }
-
-      if (input.copyAnyScore != null) {
-        labelType.setCopyAnyScore(input.copyAnyScore);
-      }
-
-      if (input.copyMinScore != null) {
-        labelType.setCopyMinScore(input.copyMinScore);
-      }
-
-      if (input.copyMaxScore != null) {
-        labelType.setCopyMaxScore(input.copyMaxScore);
-      }
-
-      if (input.copyAllScoresIfNoChange != null) {
-        labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
-      }
-
-      if (input.copyAllScoresIfNoCodeChange != null) {
-        labelType.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
-      }
-
-      if (input.copyAllScoresOnTrivialRebase != null) {
-        labelType.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
-      }
-
-      if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
-        labelType.setCopyAllScoresOnMergeFirstParentUpdate(
-            input.copyAllScoresOnMergeFirstParentUpdate);
-      }
-
-      if (input.allowPostSubmit != null) {
-        labelType.setAllowPostSubmit(input.allowPostSubmit);
-      }
-
-      if (input.ignoreSelfApproval != null) {
-        labelType.setIgnoreSelfApproval(input.ignoreSelfApproval);
-      }
+      LabelType labelType = createLabel(config, id.get(), input);
 
       if (input.commitMessage != null) {
         md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
@@ -177,7 +99,6 @@
         md.setMessage("Update label");
       }
 
-      config.getLabelSections().put(labelType.getName(), labelType);
       config.commit(md);
 
       projectCache.evict(rsrc.getProjectState().getProject());
@@ -185,4 +106,101 @@
       return Response.created(LabelDefinitionJson.format(rsrc.getNameKey(), labelType));
     }
   }
+
+  /**
+   * Creates a new label.
+   *
+   * @param config the project config
+   * @param label the name of the new label
+   * @param input the input that describes the new label
+   * @return the created label type
+   * @throws BadRequestException if there was invalid data in the input
+   * @throws ResourceConflictException if the label cannot be created due to a conflict
+   */
+  public LabelType createLabel(ProjectConfig config, String label, LabelDefinitionInput input)
+      throws BadRequestException, ResourceConflictException {
+    if (config.getLabelSections().containsKey(label)) {
+      throw new ResourceConflictException(String.format("label %s already exists", label));
+    }
+
+    for (String labelName : config.getLabelSections().keySet()) {
+      if (labelName.equalsIgnoreCase(label)) {
+        throw new ResourceConflictException(
+            String.format("label %s conflicts with existing label %s", label, labelName));
+      }
+    }
+
+    if (input.values == null || input.values.isEmpty()) {
+      throw new BadRequestException("values are required");
+    }
+
+    List<LabelValue> values = LabelDefinitionInputParser.parseValues(input.values);
+
+    LabelType labelType;
+    try {
+      labelType = new LabelType(label, values);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException("invalid name: " + label, e);
+    }
+
+    if (input.function != null && !input.function.trim().isEmpty()) {
+      labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
+    } else {
+      labelType.setFunction(LabelFunction.MAX_WITH_BLOCK);
+    }
+
+    if (input.defaultValue != null) {
+      labelType.setDefaultValue(
+          LabelDefinitionInputParser.parseDefaultValue(labelType, input.defaultValue));
+    }
+
+    if (input.branches != null) {
+      labelType.setRefPatterns(LabelDefinitionInputParser.parseBranches(input.branches));
+    }
+
+    if (input.canOverride != null) {
+      labelType.setCanOverride(input.canOverride);
+    }
+
+    if (input.copyAnyScore != null) {
+      labelType.setCopyAnyScore(input.copyAnyScore);
+    }
+
+    if (input.copyMinScore != null) {
+      labelType.setCopyMinScore(input.copyMinScore);
+    }
+
+    if (input.copyMaxScore != null) {
+      labelType.setCopyMaxScore(input.copyMaxScore);
+    }
+
+    if (input.copyAllScoresIfNoChange != null) {
+      labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
+    }
+
+    if (input.copyAllScoresIfNoCodeChange != null) {
+      labelType.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
+    }
+
+    if (input.copyAllScoresOnTrivialRebase != null) {
+      labelType.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
+    }
+
+    if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
+      labelType.setCopyAllScoresOnMergeFirstParentUpdate(
+          input.copyAllScoresOnMergeFirstParentUpdate);
+    }
+
+    if (input.allowPostSubmit != null) {
+      labelType.setAllowPostSubmit(input.allowPostSubmit);
+    }
+
+    if (input.ignoreSelfApproval != null) {
+      labelType.setIgnoreSelfApproval(input.ignoreSelfApproval);
+    }
+
+    config.getLabelSections().put(labelType.getName(), labelType);
+
+    return labelType;
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
index 5464abf..531640c 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
@@ -77,12 +77,10 @@
     try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
       ProjectConfig config = projectConfigFactory.read(md);
 
-      if (!config.getLabelSections().containsKey(rsrc.getLabelType().getName())) {
+      if (!deleteLabel(config, rsrc.getLabelType().getName())) {
         throw new ResourceNotFoundException(IdString.fromDecoded(rsrc.getLabelType().getName()));
       }
 
-      config.getLabelSections().remove(rsrc.getLabelType().getName());
-
       if (input.commitMessage != null) {
         md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
       } else {
@@ -96,4 +94,20 @@
 
     return Response.none();
   }
+
+  /**
+   * Delete the given label from the given project config.
+   *
+   * @param config the project config from which the label should be deleted
+   * @param labelName the name of the label that should be deleted
+   * @return {@code true} if the label was deleted, {@code false} if the label was not found
+   */
+  public boolean deleteLabel(ProjectConfig config, String labelName) {
+    if (!config.getLabelSections().containsKey(labelName)) {
+      return false;
+    }
+
+    config.getLabelSections().remove(labelName);
+    return true;
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
index 2c76cbd..5b3ea30 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -72,6 +72,7 @@
     get(LABEL_KIND).to(GetLabel.class);
     put(LABEL_KIND).to(SetLabel.class);
     delete(LABEL_KIND).to(DeleteLabel.class);
+    postOnCollection(LABEL_KIND).to(PostLabels.class);
 
     get(PROJECT_KIND, "HEAD").to(GetHead.class);
     put(PROJECT_KIND, "HEAD").to(SetHead.class);
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabels.java b/java/com/google/gerrit/server/restapi/project/PostLabels.java
new file mode 100644
index 0000000..8835359
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PostLabels.java
@@ -0,0 +1,148 @@
+// Copyright (C) 2019 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.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.LabelResource;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Map.Entry;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/** REST endpoint that allows to add, update and delete label definitions in a batch. */
+@Singleton
+public class PostLabels
+    implements RestCollectionModifyView<ProjectResource, LabelResource, BatchLabelInput> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final DeleteLabel deleteLabel;
+  private final CreateLabel createLabel;
+  private final SetLabel setLabel;
+  private final ProjectCache projectCache;
+
+  @Inject
+  public PostLabels(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      DeleteLabel deleteLabel,
+      CreateLabel createLabel,
+      SetLabel setLabel,
+      ProjectCache projectCache) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.deleteLabel = deleteLabel;
+    this.createLabel = createLabel;
+    this.setLabel = setLabel;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<?> apply(ProjectResource rsrc, BatchLabelInput input)
+      throws AuthException, UnprocessableEntityException, PermissionBackendException, IOException,
+          ConfigInvalidException, BadRequestException, ResourceConflictException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    if (input == null) {
+      input = new BatchLabelInput();
+    }
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
+      boolean dirty = false;
+
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      if (input.delete != null && !input.delete.isEmpty()) {
+        for (String labelName : input.delete) {
+          if (!deleteLabel.deleteLabel(config, labelName.trim())) {
+            throw new UnprocessableEntityException(String.format("label %s not found", labelName));
+          }
+        }
+        dirty = true;
+      }
+
+      if (input.create != null && !input.create.isEmpty()) {
+        for (LabelDefinitionInput labelInput : input.create) {
+          if (labelInput.name == null || labelInput.name.trim().isEmpty()) {
+            throw new BadRequestException("label name is required for new label");
+          }
+          if (labelInput.commitMessage != null) {
+            throw new BadRequestException("commit message on label definition input not supported");
+          }
+          createLabel.createLabel(config, labelInput.name.trim(), labelInput);
+        }
+        dirty = true;
+      }
+
+      if (input.update != null && !input.update.isEmpty()) {
+        for (Entry<String, LabelDefinitionInput> e : input.update.entrySet()) {
+          LabelType labelType = config.getLabelSections().get(e.getKey().trim());
+          if (labelType == null) {
+            throw new UnprocessableEntityException(String.format("label %s not found", e.getKey()));
+          }
+          if (e.getValue().commitMessage != null) {
+            throw new BadRequestException("commit message on label definition input not supported");
+          }
+          setLabel.updateLabel(config, labelType, e.getValue());
+        }
+        dirty = true;
+      }
+
+      if (input.commitMessage != null) {
+        md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
+      } else {
+        md.setMessage("Update labels");
+      }
+
+      if (dirty) {
+        config.commit(md);
+        projectCache.evict(rsrc.getProjectState().getProject());
+      }
+    }
+
+    return Response.ok("");
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index b7cffce..824b4ed 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -80,117 +80,9 @@
     LabelType labelType = rsrc.getLabelType();
 
     try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
-      boolean dirty = false;
-
       ProjectConfig config = projectConfigFactory.read(md);
-      config.getLabelSections().remove(labelType.getName());
 
-      if (input.name != null) {
-        String newName = input.name.trim();
-        if (newName.isEmpty()) {
-          throw new BadRequestException("name cannot be empty");
-        }
-        if (!newName.equals(labelType.getName())) {
-          if (config.getLabelSections().containsKey(newName)) {
-            throw new ResourceConflictException(String.format("name %s already in use", newName));
-          }
-
-          for (String labelName : config.getLabelSections().keySet()) {
-            if (labelName.equalsIgnoreCase(newName)) {
-              throw new ResourceConflictException(
-                  String.format("name %s conflicts with existing label %s", newName, labelName));
-            }
-          }
-
-          try {
-            labelType.setName(newName);
-          } catch (IllegalArgumentException e) {
-            throw new BadRequestException("invalid name: " + input.name, e);
-          }
-          dirty = true;
-        }
-      }
-
-      if (input.function != null) {
-        if (input.function.trim().isEmpty()) {
-          throw new BadRequestException("function cannot be empty");
-        }
-        labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
-        dirty = true;
-      }
-
-      if (input.values != null) {
-        if (input.values.isEmpty()) {
-          throw new BadRequestException("values cannot be empty");
-        }
-        labelType.setValues(LabelDefinitionInputParser.parseValues(input.values));
-        dirty = true;
-      }
-
-      if (input.defaultValue != null) {
-        labelType.setDefaultValue(
-            LabelDefinitionInputParser.parseDefaultValue(labelType, input.defaultValue));
-        dirty = true;
-      }
-
-      if (input.branches != null) {
-        labelType.setRefPatterns(LabelDefinitionInputParser.parseBranches(input.branches));
-        dirty = true;
-      }
-
-      if (input.canOverride != null) {
-        labelType.setCanOverride(input.canOverride);
-        dirty = true;
-      }
-
-      if (input.copyAnyScore != null) {
-        labelType.setCopyAnyScore(input.copyAnyScore);
-        dirty = true;
-      }
-
-      if (input.copyMinScore != null) {
-        labelType.setCopyMinScore(input.copyMinScore);
-        dirty = true;
-      }
-
-      if (input.copyMaxScore != null) {
-        labelType.setCopyMaxScore(input.copyMaxScore);
-        dirty = true;
-      }
-
-      if (input.copyAllScoresIfNoChange != null) {
-        labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
-      }
-
-      if (input.copyAllScoresIfNoCodeChange != null) {
-        labelType.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
-        dirty = true;
-      }
-
-      if (input.copyAllScoresOnTrivialRebase != null) {
-        labelType.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
-        dirty = true;
-      }
-
-      if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
-        labelType.setCopyAllScoresOnMergeFirstParentUpdate(
-            input.copyAllScoresOnMergeFirstParentUpdate);
-        dirty = true;
-      }
-
-      if (input.allowPostSubmit != null) {
-        labelType.setAllowPostSubmit(input.allowPostSubmit);
-        dirty = true;
-      }
-
-      if (input.ignoreSelfApproval != null) {
-        labelType.setIgnoreSelfApproval(input.ignoreSelfApproval);
-        dirty = true;
-      }
-
-      if (dirty) {
-        config.getLabelSections().put(labelType.getName(), labelType);
-
+      if (updateLabel(config, labelType, input)) {
         if (input.commitMessage != null) {
           md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
         } else {
@@ -203,4 +95,128 @@
     }
     return Response.ok(LabelDefinitionJson.format(rsrc.getProject().getNameKey(), labelType));
   }
+
+  /**
+   * Updates the given label.
+   *
+   * @param config the project config
+   * @param labelType the label type that should be updated
+   * @param input the input that describes the label update
+   * @return whether the label type was modified
+   * @throws BadRequestException if there was invalid data in the input
+   * @throws ResourceConflictException if the update cannot be applied due to a conflict
+   */
+  public boolean updateLabel(ProjectConfig config, LabelType labelType, LabelDefinitionInput input)
+      throws BadRequestException, ResourceConflictException {
+    boolean dirty = false;
+
+    config.getLabelSections().remove(labelType.getName());
+
+    if (input.name != null) {
+      String newName = input.name.trim();
+      if (newName.isEmpty()) {
+        throw new BadRequestException("name cannot be empty");
+      }
+      if (!newName.equals(labelType.getName())) {
+        if (config.getLabelSections().containsKey(newName)) {
+          throw new ResourceConflictException(String.format("name %s already in use", newName));
+        }
+
+        for (String labelName : config.getLabelSections().keySet()) {
+          if (labelName.equalsIgnoreCase(newName)) {
+            throw new ResourceConflictException(
+                String.format("name %s conflicts with existing label %s", newName, labelName));
+          }
+        }
+
+        try {
+          labelType.setName(newName);
+        } catch (IllegalArgumentException e) {
+          throw new BadRequestException("invalid name: " + input.name, e);
+        }
+        dirty = true;
+      }
+    }
+
+    if (input.function != null) {
+      if (input.function.trim().isEmpty()) {
+        throw new BadRequestException("function cannot be empty");
+      }
+      labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
+      dirty = true;
+    }
+
+    if (input.values != null) {
+      if (input.values.isEmpty()) {
+        throw new BadRequestException("values cannot be empty");
+      }
+      labelType.setValues(LabelDefinitionInputParser.parseValues(input.values));
+      dirty = true;
+    }
+
+    if (input.defaultValue != null) {
+      labelType.setDefaultValue(
+          LabelDefinitionInputParser.parseDefaultValue(labelType, input.defaultValue));
+      dirty = true;
+    }
+
+    if (input.branches != null) {
+      labelType.setRefPatterns(LabelDefinitionInputParser.parseBranches(input.branches));
+      dirty = true;
+    }
+
+    if (input.canOverride != null) {
+      labelType.setCanOverride(input.canOverride);
+      dirty = true;
+    }
+
+    if (input.copyAnyScore != null) {
+      labelType.setCopyAnyScore(input.copyAnyScore);
+      dirty = true;
+    }
+
+    if (input.copyMinScore != null) {
+      labelType.setCopyMinScore(input.copyMinScore);
+      dirty = true;
+    }
+
+    if (input.copyMaxScore != null) {
+      labelType.setCopyMaxScore(input.copyMaxScore);
+      dirty = true;
+    }
+
+    if (input.copyAllScoresIfNoChange != null) {
+      labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
+    }
+
+    if (input.copyAllScoresIfNoCodeChange != null) {
+      labelType.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
+      dirty = true;
+    }
+
+    if (input.copyAllScoresOnTrivialRebase != null) {
+      labelType.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
+      dirty = true;
+    }
+
+    if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
+      labelType.setCopyAllScoresOnMergeFirstParentUpdate(
+          input.copyAllScoresOnMergeFirstParentUpdate);
+      dirty = true;
+    }
+
+    if (input.allowPostSubmit != null) {
+      labelType.setAllowPostSubmit(input.allowPostSubmit);
+      dirty = true;
+    }
+
+    if (input.ignoreSelfApproval != null) {
+      labelType.setIgnoreSelfApproval(input.ignoreSelfApproval);
+      dirty = true;
+    }
+
+    config.getLabelSections().put(labelType.getName(), labelType);
+
+    return dirty;
+  }
 }
diff --git a/java/com/google/gerrit/testing/IndexConfig.java b/java/com/google/gerrit/testing/IndexConfig.java
index 21c49dd..fb6c926 100644
--- a/java/com/google/gerrit/testing/IndexConfig.java
+++ b/java/com/google/gerrit/testing/IndexConfig.java
@@ -23,7 +23,9 @@
 
   public static Config createFromExistingConfig(Config cfg) {
     cfg.setInt("index", null, "maxPages", 10);
-    cfg.setBoolean("index", null, "reindexAfterRefUpdate", false);
+    // To avoid this flakiness indexMergeable is switched off for the tests as it incurs background
+    // reindex calls.
+    cfg.setBoolean("index", "change", "indexMergeable", false);
     cfg.setString("trackingid", "query-bug", "footer", "Bug:");
     cfg.setString("trackingid", "query-bug", "match", "QUERY\\d{2,8}");
     cfg.setString("trackingid", "query-bug", "system", "querytests");
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index e38babb..7ecb07e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -4396,6 +4396,20 @@
     }
   }
 
+  @Test
+  @GerritConfig(name = "index.change.indexMergeable", value = "true")
+  public void changeQueryReturnsMergeableWhenGerritIndexMergeable() throws Exception {
+    String changeId = createChange().getChangeId();
+    assertThat(gApi.changes().query(changeId).get().get(0).mergeable).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "index.change.indexMergeable", value = "false")
+  public void changeQueryDoesNotReturnMergeableWhenGerritDoesNotIndexMergeable() throws Exception {
+    String changeId = createChange().getChangeId();
+    assertThat(gApi.changes().query(changeId).get().get(0).mergeable).isNull();
+  }
+
   private PushOneCommit.Result createWorkInProgressChange() throws Exception {
     return pushTo("refs/for/master%wip");
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangeIT.java
index 54c5d05..b5d673c 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangeIT.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.stream.Collectors.toList;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -182,6 +183,18 @@
         .containsExactly(changeId3, changeId4);
   }
 
+  @Test
+  public void usingOutOfRangeLabelValuesDoesNotCauseError() throws Exception {
+    for (String operator : ImmutableList.of("=", ">", ">=", "<", "<=")) {
+      QueryChanges queryChanges = queryChangesProvider.get();
+      queryChanges.addQuery("label:Code-Review" + operator + "10");
+      queryChanges.addQuery("label:Code-Review" + operator + "-10");
+      queryChanges.addQuery("Code-Review" + operator + "10");
+      queryChanges.addQuery("Code-Review" + operator + "-10");
+      assertThat(queryChanges.apply(TopLevelResource.INSTANCE).statusCode()).isEqualTo(SC_OK);
+    }
+  }
+
   private static void assertNoChangeHasMoreChangesSet(List<ChangeInfo> results) {
     for (ChangeInfo info : results) {
       assertThat(info._moreChanges).isNull();
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index ad73e0f..61d0fd5 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
@@ -1062,6 +1063,7 @@
   }
 
   @Test
+  @GerritConfig(name = "index.change.indexMergeable", value = "true")
   public void mergeable() throws Exception {
     ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
 
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index f0312de..d1d197b 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -1446,7 +1446,7 @@
   private TestRefAdvertiser.Result getReceivePackRefs() throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
       AdvertiseRefsHook adv =
-          ReceiveCommitsAdvertiseRefsHookChain.createForTest(queryProvider, project);
+          ReceiveCommitsAdvertiseRefsHookChain.createForTest(queryProvider, project, admin.id());
       ReceivePack rp = new ReceivePack(repo);
       rp.setAdvertiseRefsHook(adv);
       TestRefAdvertiser advertiser = new TestRefAdvertiser(repo);
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index b8ab752..55eeaf4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -83,7 +83,8 @@
               .expectedResponseCode(SC_NOT_FOUND)
               .build(),
           RestCall.get("/projects/%s/dashboards"),
-          RestCall.put("/projects/%s/labels/new-label"));
+          RestCall.put("/projects/%s/labels/new-label"),
+          RestCall.post("/projects/%s/labels/"));
 
   /**
    * Child project REST endpoints to be tested, each URL contains placeholders for the parent
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index aaf52a1..eff98b3 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -1205,7 +1205,7 @@
   }
 
   @Test
-  @GerritConfig(name = "index.reindexAfterRefUpdate", value = "true")
+  @GerritConfig(name = "index.change.indexMergeable", value = "true")
   public void submitSchedulesOpenChangesOfSameBranchForReindexing() throws Throwable {
     // Create a merged change.
     PushOneCommit push =
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index 3030b02..ea3a6a0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -58,6 +59,7 @@
   }
 
   @Test
+  @GerritConfig(name = "index.change.indexMergeable", value = "true")
   public void indexChangeAfterOwnerLosesVisibility() throws Exception {
     // Create a test group with 2 users as members
     TestAccount user2 = accountCreator.user2();
diff --git a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
new file mode 100644
index 0000000..d8132b7
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.group;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gson.reflect.TypeToken;
+import java.util.Map;
+import org.junit.Test;
+
+public class ListGroupsIT extends AbstractDaemonTest {
+  @Test
+  public void listAllGroups() throws Exception {
+    RestResponse response = adminRestSession.get("/groups/");
+    response.assertOK();
+
+    Map<String, GroupInfo> groupMap =
+        newGson()
+            .fromJson(response.getReader(), new TypeToken<Map<String, GroupInfo>>() {}.getType());
+    assertThat(groupMap.keySet()).containsExactly("Administrators", "Non-Interactive Users");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
new file mode 100644
index 0000000..9e6b051
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
@@ -0,0 +1,456 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.restapi.project.PostLabels;
+import com.google.inject.Inject;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+/** Tests for the {@link PostLabels} REST endpoint. */
+public class PostLabelsIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void anonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(allProjects.get()).labels(new BatchLabelInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void notAllowed() throws Exception {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(allProjects.get()).labels(new BatchLabelInput()));
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
+  }
+
+  @Test
+  public void deleteNonExistingLabel() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo");
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo not found");
+  }
+
+  @Test
+  public void deleteLabels() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+    configLabel("Bar", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).labels().get()).isNotEmpty();
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo", "Bar");
+    gApi.projects().name(project.get()).labels(input);
+    assertThat(gApi.projects().name(project.get()).labels().get()).isEmpty();
+  }
+
+  @Test
+  public void deleteLabels_labelNamesAreTrimmed() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+    configLabel("Bar", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).labels().get()).isNotEmpty();
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of(" Foo ", " Bar ");
+    gApi.projects().name(project.get()).labels(input);
+    assertThat(gApi.projects().name(project.get()).labels().get()).isEmpty();
+  }
+
+  @Test
+  public void cannotDeleteTheSameLabelTwice() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo", "Foo");
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(project.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo not found");
+  }
+
+  @Test
+  public void cannotCreateLabelWithNameThatIsAlreadyInUse() throws Exception {
+    LabelDefinitionInput labelInput = new LabelDefinitionInput();
+    labelInput.name = "Code-Review";
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(labelInput);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Code-Review already exists");
+  }
+
+  @Test
+  public void cannotCreateTwoLabelsWithTheSameName() throws Exception {
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.name = "Foo";
+    fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(fooInput, fooInput);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo already exists");
+  }
+
+  @Test
+  public void cannotCreateTwoLabelsWithNamesThatAreTheSameAfterTrim() throws Exception {
+    LabelDefinitionInput foo1Input = new LabelDefinitionInput();
+    foo1Input.name = "Foo";
+    foo1Input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput foo2Input = new LabelDefinitionInput();
+    foo2Input.name = " Foo ";
+    foo2Input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(foo1Input, foo2Input);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo already exists");
+  }
+
+  @Test
+  public void cannotCreateTwoLabelsWithConflictingNames() throws Exception {
+    LabelDefinitionInput foo1Input = new LabelDefinitionInput();
+    foo1Input.name = "Foo";
+    foo1Input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput foo2Input = new LabelDefinitionInput();
+    foo2Input.name = "foo";
+    foo2Input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(foo1Input, foo2Input);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label foo conflicts with existing label Foo");
+  }
+
+  @Test
+  public void createLabels() throws Exception {
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.name = "Foo";
+    fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput barInput = new LabelDefinitionInput();
+    barInput.name = "Bar";
+    barInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(fooInput, barInput);
+
+    gApi.projects().name(allProjects.get()).labels(input);
+    assertThat(gApi.projects().name(allProjects.get()).label("Foo").get()).isNotNull();
+    assertThat(gApi.projects().name(allProjects.get()).label("Bar").get()).isNotNull();
+  }
+
+  @Test
+  public void createLabels_labelNamesAreTrimmed() throws Exception {
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.name = " Foo ";
+    fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput barInput = new LabelDefinitionInput();
+    barInput.name = " Bar ";
+    barInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(fooInput, barInput);
+
+    gApi.projects().name(allProjects.get()).labels(input);
+    assertThat(gApi.projects().name(allProjects.get()).label("Foo").get()).isNotNull();
+    assertThat(gApi.projects().name(allProjects.get()).label("Bar").get()).isNotNull();
+  }
+
+  @Test
+  public void cannotCreateLabelWithoutName() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(new LabelDefinitionInput());
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label name is required for new label");
+  }
+
+  @Test
+  public void cannotSetCommitMessageOnLabelDefinitionInputForCreate() throws Exception {
+    LabelDefinitionInput labelInput = new LabelDefinitionInput();
+    labelInput.name = "Foo";
+    labelInput.commitMessage = "Create Label Foo";
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(labelInput);
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("commit message on label definition input not supported");
+  }
+
+  @Test
+  public void updateNonExistingLabel() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.update = ImmutableMap.of("Foo", new LabelDefinitionInput());
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo not found");
+  }
+
+  @Test
+  public void updateLabels() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+    configLabel("Bar", LabelFunction.NO_OP);
+
+    LabelDefinitionInput fooUpdate = new LabelDefinitionInput();
+    fooUpdate.function = LabelFunction.MAX_WITH_BLOCK.getFunctionName();
+    LabelDefinitionInput barUpdate = new LabelDefinitionInput();
+    barUpdate.name = "Baz";
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.update = ImmutableMap.of("Foo", fooUpdate, "Bar", barUpdate);
+
+    gApi.projects().name(project.get()).labels(input);
+
+    assertThat(gApi.projects().name(project.get()).label("Foo").get().function)
+        .isEqualTo(fooUpdate.function);
+    assertThat(gApi.projects().name(project.get()).label("Baz").get()).isNotNull();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).label("Bar").get());
+  }
+
+  @Test
+  public void updateLabels_labelNamesAreTrimmed() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+    configLabel("Bar", LabelFunction.NO_OP);
+
+    LabelDefinitionInput fooUpdate = new LabelDefinitionInput();
+    fooUpdate.function = LabelFunction.MAX_WITH_BLOCK.getFunctionName();
+    LabelDefinitionInput barUpdate = new LabelDefinitionInput();
+    barUpdate.name = "Baz";
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.update = ImmutableMap.of(" Foo ", fooUpdate, " Bar ", barUpdate);
+
+    gApi.projects().name(project.get()).labels(input);
+
+    assertThat(gApi.projects().name(project.get()).label("Foo").get().function)
+        .isEqualTo(fooUpdate.function);
+    assertThat(gApi.projects().name(project.get()).label("Baz").get()).isNotNull();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).label("Bar").get());
+  }
+
+  @Test
+  public void cannotSetCommitMessageOnLabelDefinitionInputForUpdate() throws Exception {
+    LabelDefinitionInput labelInput = new LabelDefinitionInput();
+    labelInput.commitMessage = "Update label";
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.update = ImmutableMap.of("Code-Review", labelInput);
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("commit message on label definition input not supported");
+  }
+
+  @Test
+  public void deleteAndRecreateLabel() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.name = "Foo";
+    fooInput.function = LabelFunction.MAX_NO_BLOCK.getFunctionName();
+    fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo");
+    input.create = ImmutableList.of(fooInput);
+
+    gApi.projects().name(project.get()).labels(input);
+
+    LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("Foo").get();
+    assertThat(fooLabel.function).isEqualTo(fooInput.function);
+  }
+
+  @Test
+  public void deleteRecreateAndUpdateLabel() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+
+    LabelDefinitionInput fooCreateInput = new LabelDefinitionInput();
+    fooCreateInput.name = "Foo";
+    fooCreateInput.function = LabelFunction.MAX_NO_BLOCK.getFunctionName();
+    fooCreateInput.values =
+        ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput fooUpdateInput = new LabelDefinitionInput();
+    fooUpdateInput.function = LabelFunction.ANY_WITH_BLOCK.getFunctionName();
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo");
+    input.create = ImmutableList.of(fooCreateInput);
+    input.update = ImmutableMap.of("Foo", fooUpdateInput);
+
+    gApi.projects().name(project.get()).labels(input);
+
+    LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("Foo").get();
+    assertThat(fooLabel.function).isEqualTo(fooUpdateInput.function);
+  }
+
+  @Test
+  public void cannotDeleteAndUpdateLabel() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.function = LabelFunction.MAX_NO_BLOCK.getFunctionName();
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo");
+    input.update = ImmutableMap.of("Foo", fooInput);
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(project.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo not found");
+  }
+
+  @Test
+  public void createAndUpdateLabel() throws Exception {
+    LabelDefinitionInput fooCreateInput = new LabelDefinitionInput();
+    fooCreateInput.name = "Foo";
+    fooCreateInput.function = LabelFunction.MAX_NO_BLOCK.getFunctionName();
+    fooCreateInput.values =
+        ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput fooUpdateInput = new LabelDefinitionInput();
+    fooUpdateInput.function = LabelFunction.ANY_WITH_BLOCK.getFunctionName();
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(fooCreateInput);
+    input.update = ImmutableMap.of("Foo", fooUpdateInput);
+
+    gApi.projects().name(project.get()).labels(input);
+
+    LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("Foo").get();
+    assertThat(fooLabel.function).isEqualTo(fooUpdateInput.function);
+  }
+
+  @Test
+  public void noOpUpdate() throws Exception {
+    RevCommit refsMetaConfigHead =
+        projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG);
+
+    gApi.projects().name(allProjects.get()).labels(new BatchLabelInput());
+
+    assertThat(projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void defaultCommitMessage() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Code-Review");
+    gApi.projects().name(allProjects.get()).labels(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Update labels");
+  }
+
+  @Test
+  public void withCommitMessage() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.commitMessage = "Batch Update Labels";
+    input.delete = ImmutableList.of("Code-Review");
+    gApi.projects().name(allProjects.get()).labels(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo(input.commitMessage);
+  }
+
+  @Test
+  public void commitMessageIsTrimmed() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.commitMessage = " Batch Update Labels ";
+    input.delete = ImmutableList.of("Code-Review");
+    gApi.projects().name(allProjects.get()).labels(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Batch Update Labels");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index b32ce35..65f8aa0 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -29,9 +29,11 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -68,6 +70,7 @@
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -81,6 +84,7 @@
   @Inject private Provider<ChangesCollection> changes;
   @Inject private Provider<PostReview> postReview;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject ProjectOperations projectOperations;
 
   private final Integer[] lines = {0, 1};
 
@@ -314,6 +318,47 @@
   }
 
   @Test
+  public void postCommentsUnreachableData() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+
+    String file = "file";
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "first subject", file, "l1\nl2\n");
+
+    String dest = "refs/for/master";
+    PushOneCommit.Result r1 = push.to(dest);
+    r1.assertOkStatus();
+    String changeId = r1.getChangeId();
+    String revId = r1.getCommit().getName();
+
+    PushOneCommit.Result r2 = amendChange(r1.getChangeId());
+    r2.assertOkStatus();
+
+    String draftRefName = RefNames.refsDraftComments(r1.getChange().getId(), admin.id());
+
+    DraftInput draft = newDraft(file, Side.REVISION, 1, "comment");
+    addDraft(changeId, "1", draft);
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.drafts = DraftHandling.PUBLISH;
+    reviewInput.message = "foo";
+    gApi.changes().id(r1.getChangeId()).revision(1).review(reviewInput);
+
+    addDraft(changeId, "2", newDraft(file, Side.REVISION, 2, "comment2"));
+    reviewInput = new ReviewInput();
+    reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    reviewInput.message = "bar";
+    gApi.changes().id(r1.getChangeId()).revision(2).review(reviewInput);
+
+    Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
+    assertThat(drafts.isEmpty()).isTrue();
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      Ref ref = repo.exactRef(draftRefName);
+      assertThat(ref).isNull();
+    }
+  }
+
+  @Test
   public void listComments() throws Exception {
     String file = "file";
     PushOneCommit push =
diff --git a/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java b/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
index 7c78d61..b23f9a3 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
@@ -39,7 +39,9 @@
 import com.google.gerrit.server.patch.Text;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
+import java.lang.reflect.Field;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Optional;
@@ -66,6 +68,25 @@
   private Cache<PatchListKey, PatchList> abstractPatchListCache;
 
   @Test
+  public void ensureLegacyBackendIsUsedForFileCacheBackend() throws Exception {
+    Field fileCacheField = patchListCache.getClass().getDeclaredField("fileCache");
+    fileCacheField.setAccessible(true);
+    // Use the reflection to access "localCache" field that is only present in Guava backend.
+    assertThat(
+            Arrays.stream(fileCacheField.get(patchListCache).getClass().getDeclaredFields())
+                .anyMatch(f -> f.getName().equals("localCache")))
+        .isTrue();
+
+    // intraCache (and all other cache backends) should use Caffeine backend.
+    Field intraCacheField = patchListCache.getClass().getDeclaredField("intraCache");
+    intraCacheField.setAccessible(true);
+    assertThat(
+            Arrays.stream(intraCacheField.get(patchListCache).getClass().getDeclaredFields())
+                .noneMatch(f -> f.getName().equals("localCache")))
+        .isTrue();
+  }
+
+  @Test
   public void listPatchesAgainstBase() throws Exception {
     commitBuilder().add(FILE_D, "4").message(SUBJECT_1).create();
     pushHead(testRepo, "refs/heads/master", false);
diff --git a/javatests/com/google/gerrit/index/query/RangeUtilTest.java b/javatests/com/google/gerrit/index/query/RangeUtilTest.java
new file mode 100644
index 0000000..681f9d99b
--- /dev/null
+++ b/javatests/com/google/gerrit/index/query/RangeUtilTest.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2019 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.index.query;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.query.RangeUtil.Range;
+import org.junit.Test;
+
+public class RangeUtilTest {
+  @Test
+  public void getRangeForValueOutsideOfMinMaxRange_minNotGreaterThanMax() {
+    for (String operator : ImmutableList.of("=", ">", ">=", "<", "<=")) {
+      Range range = RangeUtil.getRange("foo", operator, 10, -4, 4);
+      assertThat(range.min).isAtMost(range.max);
+
+      range = RangeUtil.getRange("foo", operator, -10, -4, 4);
+      assertThat(range.min).isAtMost(range.max);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index 9a48a68..3dfbefe 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import org.eclipse.jgit.lib.Config;
 import org.junit.Ignore;
 
 @Ignore
@@ -26,8 +27,34 @@
     super(
         new ChangeQueryBuilder.Definition<>(FakeQueryBuilder.class),
         new ChangeQueryBuilder.Arguments(
-            null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, null, indexes, null, null, null, null, null, null, null, null));
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            indexes,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            new Config()));
   }
 
   @Operator
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 231340d..56b3aea 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -30,6 +30,7 @@
 import static java.util.concurrent.TimeUnit.MINUTES;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.toList;
+import static org.hamcrest.MatcherAssert.assertThat;
 import static org.junit.Assert.fail;
 
 import com.google.common.base.MoreObjects;
@@ -41,6 +42,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Streams;
 import com.google.common.truth.ThrowableSubject;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
@@ -79,6 +81,7 @@
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
@@ -2023,6 +2026,7 @@
   }
 
   @Test
+  @GerritConfig(name = "index.change.indexMergeable", value = "true")
   public void mergeable() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
@@ -2040,7 +2044,7 @@
     // If a change gets submitted, the remaining open changes get reindexed asynchronously to update
     // their mergeability information. If the further assertions in this test are done before the
     // asynchronous reindex completed they fail because the mergeability information in the index
-    // was not updated yet. To avoid this flakiness reindexAfterRefUpdate is switched off for the
+    // was not updated yet. To avoid this flakiness indexMergeable is switched off for the
     // tests and we index change2 synchronously here.
     gApi.changes().id(change2.getChangeId()).index();
 
@@ -3079,6 +3083,20 @@
     }
   }
 
+  @Test
+  @GerritConfig(name = "index.change.indexMergeable", value = "false")
+  public void mergeableFailsWhenNotIndexed() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
+    insert(repo, newChangeForCommit(repo, commit1));
+
+    Throwable thrown = assertThrows(Throwable.class, () -> assertQuery("status:open is:mergeable"));
+    assertThat(thrown.getCause()).isInstanceOf(QueryParseException.class);
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("server does not support 'mergeable'. check configs");
+  }
+
   protected ChangeInserter newChange(TestRepository<Repo> repo) throws Exception {
     return newChange(repo, null, null, null, null, false);
   }
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index d0162d3..e5b51e7 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -16,12 +16,14 @@
         "//prolog:gerrit-prolog-common",
     ],
     deps = [
+        "//java/com/google/gerrit/acceptance/config",
         "//java/com/google/gerrit/acceptance/testsuite/project",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
diff --git a/lib/BUILD b/lib/BUILD
index 13580b1..39c622a 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -1,4 +1,4 @@
-load("@rules_java//java:defs.bzl", "java_library")
+load("@rules_java//java:defs.bzl", "java_import", "java_library")
 
 exports_files(glob([
     "LICENSE-*",
@@ -111,6 +111,29 @@
 )
 
 java_library(
+    name = "caffeine",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = [
+        "//java/com/google/gerrit/server/cache/mem:__pkg__",
+    ],
+    exports = ["@caffeine//jar"],
+)
+
+java_import(
+    name = "caffeine-guava-renamed",
+    jars = ["@caffeine-guava-renamed//file"],
+)
+
+java_library(
+    name = "caffeine-guava",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = [
+        "//java/com/google/gerrit/server/cache/mem:__pkg__",
+    ],
+    exports = [":caffeine-guava-renamed"],
+)
+
+java_library(
     name = "jsch",
     data = ["//lib:LICENSE-jsch"],
     visibility = ["//visibility:public"],
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
index ffc1b9b..1d6e706 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
@@ -43,11 +43,6 @@
       .value {
         width: 32em;
       }
-      gr-autocomplete {
-        --gr-autocomplete: {
-          padding: 0 var(--spacing-xs);
-        }
-      }
       .hide {
         display: none;
       }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
index 8d03595..b78090c 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
@@ -40,14 +40,7 @@
         width: 20em;
       }
       gr-autocomplete {
-        border: none;
-        --gr-autocomplete: {
-          border: 1px solid var(--border-color);
-          border-radius: var(--border-radius);
-          height: 2em;
-          padding: 0 var(--spacing-xs);
-          width: 20em;
-        }
+        width: 20em;
       }
     </style>
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
index bc7a109..cf24793 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
@@ -48,10 +48,6 @@
       }
       gr-autocomplete {
         width: 20em;
-        --gr-autocomplete: {
-          height: 2em;
-          width: 20em;
-        }
       }
       a {
         color: var(--primary-text-color);
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
index 0cdef8c..1b57ddb 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
@@ -29,11 +29,9 @@
       }
       gr-autocomplete {
         background-color: var(--view-background-color);
-        border: 1px solid var(--border-color);
         border-radius: var(--border-radius);
         flex: 1;
         outline: none;
-        padding: var(--spacing-xs);
       }
     </style>
     <form>
@@ -45,7 +43,6 @@
           on-commit="_handleInputCommit"
           allow-non-suggested-values
           multi
-          borderless
           threshold="[[_threshold]]"
           tab-complete
           vertical-offset="30"></gr-autocomplete>
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js
index b7994e6..4009420 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js
@@ -17,16 +17,20 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'comment-api-mock',
+  class CommentApiMock extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'comment-api-mock'; }
 
-    properties: {
-      _changeComments: Object,
-    },
+    static get properties() {
+      return {
+        _changeComments: Object,
+      };
+    }
 
     loadComments() {
       return this._reloadComments();
-    },
+    }
 
     /**
      * For the purposes of the mock, _reloadDrafts is not included because its
@@ -38,13 +42,15 @@
       return this._reloadComments().then(() => {
         return e.detail.resolve();
       });
-    },
+    }
 
     _reloadComments() {
       return this.$.commentAPI.loadAll(this._changeNum)
           .then(comments => {
             this._changeComments = this.$.commentAPI._changeComments;
           });
-    },
-  });
+    }
+  }
+
+  customElements.define(CommentApiMock.is, CommentApiMock);
 })();
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-constants.html b/polygerrit-ui/app/elements/edit/gr-edit-constants.html
index d526ccd..5895124 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-constants.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-constants.html
@@ -22,7 +22,7 @@
 
     // Order corresponds to order in the UI.
     GrEditConstants.Actions = {
-      OPEN: {label: 'Open', id: 'open'},
+      OPEN: {label: 'Add/Open', id: 'open'},
       DELETE: {label: 'Delete', id: 'delete'},
       RENAME: {label: 'Rename', id: 'rename'},
       RESTORE: {label: 'Restore', id: 'restore'},
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
index 52692a7..cb950da 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
@@ -55,21 +55,13 @@
       gr-dialog .main > iron-input{
         width: 100%;
       }
-      gr-autocomplete {
-        --gr-autocomplete: {
-          border: 1px solid var(--border-color);
-          border-radius: var(--border-radius);
-          height: 2em;
-          padding: 0 var(--spacing-xs);
-        }
-      }
       input {
         border: 1px solid var(--border-color);
         border-radius: var(--border-radius);
-        height: 2em;
         margin: var(--spacing-m) 0;
-        padding: 0 var(--spacing-xs);
+        padding: var(--spacing-s);
         width: 100%;
+        box-sizing: content-box;
       }
       @media screen and (max-width: 50em) {
         gr-dialog {
@@ -89,12 +81,12 @@
           id="openDialog"
           class="invisible dialog"
           disabled$="[[!_isValidPath(_path)]]"
-          confirm-label="Open"
+          confirm-label="Confirm"
           confirm-on-enter
           on-confirm="_handleOpenConfirm"
           on-cancel="_handleDialogCancel">
         <div class="header" slot="header">
-          Open an existing or new file
+          Add a new file or open an existing file
         </div>
         <div class="main" slot="main">
           <gr-autocomplete
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
index 2027119..22ba457 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
@@ -18,6 +18,7 @@
 <link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -37,6 +38,9 @@
       #generatedPasswordDisplay {
         margin: var(--spacing-l) 0;
       }
+      #generatedPasswordDisplay .title {
+        width: unset;
+      }
       #generatedPasswordDisplay .value {
         font-family: var(--monospace-font-family);
         font-size: var(--font-size-mono);
@@ -79,6 +83,12 @@
         <section id="generatedPasswordDisplay">
           <span class="title">New Password:</span>
           <span class="value">[[_generatedPassword]]</span>
+          <gr-copy-clipboard
+              has-tooltip
+              button-title="Copy password to clipboard"
+              hide-input
+              text="[[_generatedPassword]]">
+          </gr-copy-clipboard>
         </section>
         <section id="passwordWarning">
           This password will not be displayed again.<br>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.html b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.html
index ae656fd..992ea8407 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.html
@@ -40,7 +40,8 @@
         on-commit="_handleInputCommit"
         clear-on-commit
         warn-uncommitted
-        text="{{_inputText}}">
+        text="{{_inputText}}"
+        vertical-offset="24">
     </gr-autocomplete>
   </template>
   <script src="gr-account-entry.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
index 64950f2..cf32e28 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
@@ -60,6 +60,7 @@
       .dropdown-content {
         background: var(--dropdown-background-color);
         box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
+        border-radius: var(--border-radius);
         max-height: 50vh;
         overflow: auto;
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
index c9d12ce..e47b661 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
@@ -36,13 +36,14 @@
         margin: 0 var(--spacing-xs);
         vertical-align: top;
       }
-      paper-input:not(.borderless) {
-        border: 1px solid var(--border-color);
+      paper-input.borderless {
+        border: none;
+        padding: 0;
       }
       paper-input {
-        height: var(--line-height-normal);
-        width: 100%;
-        @apply --gr-autocomplete;
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        padding: var(--spacing-s);
         --paper-input-container: {
           padding: 0;
         };
@@ -50,13 +51,25 @@
           font-size: var(--font-size-normal);
           line-height: var(--line-height-normal);
         };
+        /* This is a hack for not being able to set height:0 on the underline
+           of a paper-input 2.2.3 element. All the underline fixes below only
+           actually work in 3.x.x, so the height must be adjusted directly as
+           a workaround until we are on Polymer 3. */
+        height: var(--line-height-normal);
+        --paper-input-container-underline-height: 0;
+        --paper-input-container-underline-wrapper-height: 0;
+        --paper-input-container-underline-focus-height: 0;
+        --paper-input-container-underline-legacy-height: 0;
         --paper-input-container-underline: {
+          height: 0;
           display: none;
         };
         --paper-input-container-underline-focus: {
+          height: 0;
           display: none;
         };
         --paper-input-container-underline-disabled: {
+          height: 0;
           display: none;
         };
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
index a2c908a..3a60255 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -74,9 +74,9 @@
         },
 
         /**
-       * The number of characters that must be typed before suggestions are
-       * made. If threshold is zero, default suggestions are enabled.
-       */
+         * The number of characters that must be typed before suggestions are
+         * made. If threshold is zero, default suggestions are enabled.
+         */
         threshold: {
           type: Number,
           value: 1,
@@ -89,11 +89,15 @@
           type: Boolean,
           value: false,
         },
-        // Vertical offset needed for a 1em font-size with no vertical padding.
-        // Inputs with additional padding will need to increase vertical offset.
+        /**
+         * Vertical offset needed for an element with 20px line-height, 4px
+         * padding and 1px border (30px height total). Plus 1px spacing between
+         * input and dropdown. Inputs with different line-height or padding will
+         * need to tweak vertical offset.
+         */
         verticalOffset: {
           type: Number,
-          value: 20,
+          value: 31,
         },
 
         text: {
@@ -110,10 +114,10 @@
         },
 
         /**
-       * When true, tab key autocompletes but does not fire the commit event.
-       * When false, tab key not caught, and focus is removed from the element.
-       * See Issue 4556, Issue 6645.
-       */
+         * When true, tab key autocompletes but does not fire the commit event.
+         * When false, tab key not caught, and focus is removed from the element.
+         * See Issue 4556, Issue 6645.
+         */
         tabComplete: {
           type: Boolean,
           value: false,
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
index 68338a4..39329e5 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
@@ -335,7 +335,7 @@
     });
 
     test('vertical offset overridden by param if it exists', () => {
-      assert.equal(element.$.suggestions.verticalOffset, 20);
+      assert.equal(element.$.suggestions.verticalOffset, 31);
       element.verticalOffset = 30;
       assert.equal(element.$.suggestions.verticalOffset, 30);
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
index 2d66cfa..0ec3d6a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
@@ -50,7 +50,11 @@
       return url.pathname;
     }
     const base = Gerrit.BaseUrlBehavior.getBaseUrl();
-    const pathname = url.pathname.replace(base, '');
+    let pathname = url.pathname.replace(base, '');
+    // Load from ASSETS_PATH
+    if (window.ASSETS_PATH && url.href.includes(window.ASSETS_PATH)) {
+      pathname = url.href.replace(window.ASSETS_PATH, '');
+    }
     // Site theme is server from predefined path.
     if (pathname === '/static/gerrit-theme.html') {
       return 'gerrit-theme';
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html
index 128738d..b43796f 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html
@@ -72,6 +72,15 @@
             'gerrit-theme'
         );
       });
+
+      test('with ASSETS_PATH', () => {
+        window.ASSETS_PATH = 'http://cdn.com/2';
+        assert.equal(
+            getPluginNameFromUrl(`${window.ASSETS_PATH}/plugins/a.html`),
+            'a'
+        );
+        window.ASSETS_PATH = undefined;
+      });
     });
   });
 </script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
index 4be38b6..96d8411 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
@@ -113,7 +113,7 @@
       this._pluginListLoaded = true;
 
       plugins.forEach(path => {
-        const url = this._urlFor(path);
+        const url = this._urlFor(path, window.ASSETS_PATH);
         // Skip if preloaded, for bundling.
         if (this.isPluginPreloaded(url)) return;
 
@@ -128,11 +128,11 @@
         });
 
         if (this._isPathEndsWith(url, '.html')) {
-          this._importHtmlPlugin(url, opts && opts[path]);
+          this._importHtmlPlugin(path, opts && opts[path]);
         } else if (this._isPathEndsWith(url, '.js')) {
-          this._loadJsPlugin(url);
+          this._loadJsPlugin(path);
         } else {
-          this._failToLoad(`Unrecognized plugin url ${url}`, url);
+          this._failToLoad(`Unrecognized plugin path ${path}`, path);
         }
       });
 
@@ -181,14 +181,15 @@
         return;
       }
 
-      const pluginObject = this.getPlugin(src);
+      const url = this._urlFor(src);
+      const pluginObject = this.getPlugin(url);
       let plugin = pluginObject && pluginObject.plugin;
       if (!plugin) {
-        plugin = new Plugin(src);
+        plugin = new Plugin(url);
       }
       try {
         callback(plugin);
-        this._pluginInstalled(src, plugin);
+        this._pluginInstalled(url, plugin);
       } catch (e) {
         this._failToLoad(`${e.name}: ${e.message}`, src);
       }
@@ -313,38 +314,79 @@
     }
 
     _importHtmlPlugin(pluginUrl, opts = {}) {
-      // onload (second param) needs to be a function. When null or undefined
-      // were passed, plugins were not loaded correctly.
+      const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
+      const urlWithoutAP = this._urlFor(pluginUrl);
+      let onerror = null;
+      if (urlWithAP !== urlWithoutAP) {
+        onerror = () => this._loadHtmlPlugin(urlWithoutAP, opts.sync);
+      }
+      this._loadHtmlPlugin(urlWithAP, opts.sync, onerror);
+    }
+
+    _loadHtmlPlugin(url, sync, onerror) {
+      if (!onerror) {
+        onerror = () => {
+          this._failToLoad(`${pluginUrl} import error`, pluginUrl);
+        };
+      }
+
       (Polymer.importHref || Polymer.Base.importHref)(
-          this._urlFor(pluginUrl), () => {},
-          () => this._failToLoad(`${pluginUrl} import error`, pluginUrl),
-          !opts.sync);
+          url, () => {},
+          onerror,
+          !sync);
     }
 
     _loadJsPlugin(pluginUrl) {
-      this._createScriptTag(this._urlFor(pluginUrl));
+      const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
+      const urlWithoutAP = this._urlFor(pluginUrl);
+      let onerror = null;
+      if (urlWithAP !== urlWithoutAP) {
+        onerror = () => this._createScriptTag(urlWithoutAP);
+      }
+
+      this._createScriptTag(urlWithAP, onerror);
     }
 
-    _createScriptTag(url) {
+    _createScriptTag(url, onerror) {
+      if (!onerror) {
+        onerror = () => this._failToLoad(`${url} load error`, url);
+      }
+
       const el = document.createElement('script');
       el.defer = true;
       el.setAttribute('src', url);
-      el.onerror = () => this._failToLoad(`${url} load error`, url);
+      el.onerror = onerror;
       return document.body.appendChild(el);
     }
 
-    _urlFor(pathOrUrl) {
+    _urlFor(pathOrUrl, assetsPath) {
       if (!pathOrUrl) {
         return pathOrUrl;
       }
+
+      // theme is per host, should always load from assetsPath
+      const isThemeFile = pathOrUrl.endsWith('static/gerrit-theme.html');
+      const shouldTryLoadFromAssetsPathFirst = !isThemeFile && assetsPath;
       if (pathOrUrl.startsWith(PRELOADED_PROTOCOL) ||
           pathOrUrl.startsWith('http')) {
         // Plugins are loaded from another domain or preloaded.
+        if (pathOrUrl.includes(location.host)
+          && shouldTryLoadFromAssetsPathFirst) {
+          // if is loading from host server, try replace with cdn when assetsPath provided
+          return pathOrUrl
+              .replace(location.origin, assetsPath);
+        }
         return pathOrUrl;
       }
+
       if (!pathOrUrl.startsWith('/')) {
         pathOrUrl = '/' + pathOrUrl;
       }
+
+      if (shouldTryLoadFromAssetsPathFirst) {
+        return assetsPath + pathOrUrl;
+      }
+
       return window.location.origin + getBaseUrl() + pathOrUrl;
     }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html
index 8c1ec96..08e7e18 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html
@@ -325,11 +325,11 @@
       let loadJsPluginStub;
       setup(() => {
         importHtmlPluginStub = sandbox.stub();
-        sandbox.stub(Gerrit._pluginLoader, '_importHtmlPlugin', url => {
+        sandbox.stub(Gerrit._pluginLoader, '_loadHtmlPlugin', url => {
           importHtmlPluginStub(url);
         });
         loadJsPluginStub = sandbox.stub();
-        sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+        sandbox.stub(Gerrit._pluginLoader, '_createScriptTag', url => {
           loadJsPluginStub(url);
         });
       });
@@ -346,8 +346,8 @@
 
         assert.isTrue(failToLoadStub.calledOnce);
         assert.isTrue(failToLoadStub.calledWithExactly(
-            `Unrecognized plugin url ${url}/foo/bar`,
-            `${url}/foo/bar`
+            'Unrecognized plugin path foo/bar',
+            'foo/bar'
         ));
       });
 
@@ -407,6 +407,72 @@
       });
     });
 
+    suite('With ASSETS_PATH', () => {
+      let importHtmlPluginStub;
+      let loadJsPluginStub;
+      setup(() => {
+        window.ASSETS_PATH = 'https://cdn.com';
+        importHtmlPluginStub = sandbox.stub();
+        sandbox.stub(Gerrit._pluginLoader, '_loadHtmlPlugin', url => {
+          importHtmlPluginStub(url);
+        });
+        loadJsPluginStub = sandbox.stub();
+        sandbox.stub(Gerrit._pluginLoader, '_createScriptTag', url => {
+          loadJsPluginStub(url);
+        });
+      });
+
+      teardown(() => {
+        window.ASSETS_PATH = '';
+      });
+
+      test('Should try load plugins from assets path instead', () => {
+        Gerrit._loadPlugins([
+          'foo/bar.js',
+          'foo/bar.html',
+        ]);
+
+        assert.isTrue(importHtmlPluginStub.calledOnce);
+        assert.isTrue(
+            importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
+        );
+        assert.isTrue(loadJsPluginStub.calledOnce);
+        assert.isTrue(
+            loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
+      });
+
+      test('Should honor original path if exists', () => {
+        Gerrit._loadPlugins([
+          'http://e.com/foo/bar.html',
+          'http://e.com/foo/bar.js',
+        ]);
+
+        assert.isTrue(importHtmlPluginStub.calledOnce);
+        assert.isTrue(
+            importHtmlPluginStub.calledWithExactly(`http://e.com/foo/bar.html`)
+        );
+        assert.isTrue(loadJsPluginStub.calledOnce);
+        assert.isTrue(
+            loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`));
+      });
+
+      test('Should try replace current host with assetsPath', () => {
+        const host = window.location.origin;
+        Gerrit._loadPlugins([
+          `${host}/foo/bar.html`,
+          `${host}/foo/bar.js`,
+        ]);
+
+        assert.isTrue(importHtmlPluginStub.calledOnce);
+        assert.isTrue(
+            importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
+        );
+        assert.isTrue(loadJsPluginStub.calledOnce);
+        assert.isTrue(
+            loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
+      });
+    });
+
     test('adds js plugins will call the body', () => {
       Gerrit._loadPlugins([
         'http://e.com/foo/bar.js',
@@ -489,12 +555,10 @@
 
       test('installing preloaded plugin', () => {
         let plugin;
-        window.ASSETS_PATH = 'http://blips.com/chitz';
         Gerrit.install(p => { plugin = p; }, '0.1', 'preloaded:foo');
         assert.strictEqual(plugin.getPluginName(), 'foo');
         assert.strictEqual(plugin.url('/some/thing.html'),
-            'http://blips.com/chitz/plugins/foo/some/thing.html');
-        delete window.ASSETS_PATH;
+            'preloaded:foo/plugins/foo/some/thing.html');
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index 6c306d9..0aaeaa1 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -17,8 +17,6 @@
 (function(window) {
   'use strict';
 
-  const PRELOADED_PROTOCOL = 'preloaded:';
-
   const PANEL_ENDPOINTS_MAPPING = {
     CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK: 'change-view-integration',
     CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK: 'change-metadata-item',
@@ -66,13 +64,6 @@
 
     this._url = new URL(opt_url);
     this._name = getPluginNameFromUrl(this._url);
-    if (this._url.protocol === PRELOADED_PROTOCOL) {
-      // Original plugin URL is used in plugin assets URLs calculation.
-      const assetsBaseUrl = window.ASSETS_PATH ||
-          (window.location.origin + Gerrit.BaseUrlBehavior.getBaseUrl());
-      this._url = new URL(assetsBaseUrl + '/plugins/' + this._name +
-          '/static/' + this._name + '.js');
-    }
   }
 
   Plugin._sharedAPIElement = document.createElement('gr-js-api-interface');
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html
index da0b93f..47be6f7 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html
@@ -38,14 +38,7 @@
       #body {
         display: flex;
       }
-      gr-autocomplete {
-        height: 1.5em;
-        --gr-autocomplete: {
-          border: none;
-        }
-      }
       #trigger {
-        border-left: 1px solid var(--deemphasized-text-color);
         color: var(--deemphasized-text-color);
         cursor: pointer;
         padding-left: var(--spacing-s);
diff --git a/polygerrit-ui/app/samples/coverage-plugin.html b/polygerrit-ui/app/samples/coverage-plugin.html
index e4c23aa..fa44a47 100644
--- a/polygerrit-ui/app/samples/coverage-plugin.html
+++ b/polygerrit-ui/app/samples/coverage-plugin.html
@@ -60,6 +60,8 @@
       }).enableToggleCheckbox('Display Coverage', checkbox => {
         // Checkbox is attached so now add the notifier that will be controlled
         // by the checkbox.
+        // Checkbox will only be added to the file diff page, in the top right
+        // section near the "Diff view".
         annotationApi.addNotifier(notifyFunc => {
           new Promise(resolve => setTimeout(resolve, 3000)).then(() => {
             populateWithDummyData(coverageData);
diff --git a/polygerrit-ui/app/styles/gr-form-styles.html b/polygerrit-ui/app/styles/gr-form-styles.html
index 3fe0a72..7c9ae0d 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.html
+++ b/polygerrit-ui/app/styles/gr-form-styles.html
@@ -61,7 +61,6 @@
       }
       .gr-form-styles td,
       .gr-form-styles tfoot th {
-        height: 2em;
         padding: var(--spacing-s) 0;
         vertical-align: middle;
       }
@@ -84,8 +83,7 @@
       .gr-form-styles textarea {
         border: 1px solid var(--border-color);
         border-radius: var(--border-radius);
-        height: 2em;
-        padding: 0 var(--spacing-xs);
+        padding: var(--spacing-s);
       }
       .gr-form-styles td:last-child {
         width: 5em;
@@ -97,23 +95,16 @@
       .gr-form-styles iron-autogrow-textarea {
         border: none;
         height: auto;
-        min-height: 2em;
+        min-height: 4em;
         --iron-autogrow-textarea: {
           border: 1px solid var(--border-color);
           border-radius: var(--border-radius);
           box-sizing: border-box;
-          padding: var(--spacing-s) var(--spacing-xs) 0 var(--spacing-xs);
+          padding: var(--spacing-s);
         }
       }
       .gr-form-styles gr-autocomplete {
-        border: none;
-        --gr-autocomplete: {
-          border: 1px solid var(--border-color);
-          border-radius: var(--border-radius);
-          height: 2em;
-          padding: 0 var(--spacing-xs);
-          width: 14em;
-        }
+        width: 14em;
       }
       @media only screen and (max-width: 40em) {
         .gr-form-styles section {
diff --git a/polygerrit-ui/app/styles/shared-styles.html b/polygerrit-ui/app/styles/shared-styles.html
index 5314741..51b92e1 100644
--- a/polygerrit-ui/app/styles/shared-styles.html
+++ b/polygerrit-ui/app/styles/shared-styles.html
@@ -42,10 +42,11 @@
       input {
         background-color: inherit;
         border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
         box-sizing: border-box;
         color: var(--primary-text-color);
         margin: 0;
-        padding: 0;
+        padding: var(--spacing-s);
       }
       iron-autogrow-textarea {
         background-color: inherit;
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 35bdefd..3b699c2 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -86,6 +86,9 @@
 		log.Println("Local plugins from", "../plugins")
 	} else {
 		http.HandleFunc("/plugins/", handleProxy)
+		// Serve local plugins from `plugins_`
+		http.Handle("/plugins_/", http.StripPrefix("/plugins_/",
+			http.FileServer(http.Dir("../plugins"))))
 	}
 	log.Println("Serving on port", *port)
 	log.Fatal(http.ListenAndServe(*port, &server{}))