Merge "Test that creating 2 changes for the same commit on the same branch is not possible"
diff --git a/.bazelproject b/.bazelproject
index b1c8bd3..e14c108 100644
--- a/.bazelproject
+++ b/.bazelproject
@@ -22,6 +22,3 @@
 
 build_flags:
   --javacopt=-g
-  # Temporarily add an option to work around an error in the Bazel IntelliJ plugin.
-  # TODO(aliceks): Remove when issue is fixed.
-  --incompatible_depset_is_not_iterable=false
diff --git a/.bazelversion b/.bazelversion
index 1b58cc1..8862dba 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-0.27.0
+1.0.0rc2
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index b6c034f..d77a21d 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -2840,6 +2840,46 @@
 +
 Defaults to 300000 ms (5 minutes).
 
+
+[[index.name.maxMergeCount]]index.name.maxMergeCount::
++
+Determines the max number of simultaneous merges that are allowed. If a merge
+is necessary yet we already have this many threads running, the incoming thread
+(that is calling add/updateDocument) will block until a merge thread has
+completed.  Note that Lucene will only run the smallest maxThreadCount merges
+at a time. See the
+link:https://lucene.apache.org/core/5_5_0/core/org/apache/lucene/index/ConcurrentMergeScheduler.html#setDefaultMaxMergesAndThreads(boolean)[
+Lucene documentation] for further details.
++
+Defaults to -1 for (auto detection).
+
+
+[[index.name.maxThreadCount]]index.name.maxThreadCount::
++
+Determines the max number of simultaneous Lucene merge threads that should be running at
+once. This must be less than or equal to maxMergeCount. See the
+link:https://lucene.apache.org/core/5_5_0/core/org/apache/lucene/index/ConcurrentMergeScheduler.html#setDefaultMaxMergesAndThreads(boolean)[
+Lucene documentation] for further details.
++
+For further details on Lucene index configuration (auto detection) which
+affects maxThreadCount and maxMergeCount settings.
+See the
+link:https://lucene.apache.org/core/5_5_0/core/org/apache/lucene/index/ConcurrentMergeScheduler.html#AUTO_DETECT_MERGES_AND_THREADS[
+Lucene documentation]
++
+Defaults to -1 for (auto detection).
+
+[[index.name.enableAutoIOThrottle]]index.name.enableAutoIOThrottle::
++
+Allows the control of whether automatic IO throttling is enabled and used by
+default in the lucene merge queue.  Automatic dynamic IO throttling, which when
+on is used to adaptively rate limit writes bytes/sec to the minimal rate necessary
+so merges do not fall behind. See the
+link:https://lucene.apache.org/core/5_5_0/core/org/apache/lucene/index/ConcurrentMergeScheduler.html#enableAutoIOThrottle()[
+Lucene documentation] for further details.
++
+Defaults to true (throttling enabled).
+
 Sample Lucene index configuration:
 ----
 [index]
@@ -2848,10 +2888,17 @@
 [index "changes_open"]
   ramBufferSize = 60 m
   maxBufferedDocs = 3000
+  maxThreadCount = 5
+  maxMergeCount = 50
+
 
 [index "changes_closed"]
   ramBufferSize = 20 m
   maxBufferedDocs = 500
+  maxThreadCount = 10
+  maxMergeCount = 100
+  enableIOThrottle = false
+
 ----
 
 [[elasticsearch]]
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
index 76baa8b..bf4453c 100644
--- a/Documentation/dev-crafting-changes.txt
+++ b/Documentation/dev-crafting-changes.txt
@@ -115,7 +115,7 @@
 link:https://github.com/google/google-java-format[`google-java-format`]
 tool (version 1.7), and to format Bazel BUILD, WORKSPACE and .bzl files the
 link:https://github.com/bazelbuild/buildtools/tree/master/buildifier[`buildifier`]
-tool (version 0.26.0).
+tool (version 0.29.0).
 These tools automatically apply format according to the style guides; this
 streamlines code review by reducing the need for time-consuming, tedious,
 and contentious discussions about trivial issues like whitespace.
diff --git a/Documentation/dev-e2e-tests.txt b/Documentation/dev-e2e-tests.txt
new file mode 100644
index 0000000..7329a43
--- /dev/null
+++ b/Documentation/dev-e2e-tests.txt
@@ -0,0 +1,92 @@
+= Gerrit Code Review - End to end load tests
+
+This document provides a description of a Gerrit load test scenario implemented using the link:http://gatling.io[`Gatling`] framework.
+
+Similar scenarios have been successfully used to compare performance of different Gerrit versions or study the Gerrit response
+under different load profiles.
+
+== What is Gatling?
+
+Gatling is a load testing tool which provides out of the box support for the HTTP protocol. Documentation on how to write an
+HTTP load test can be found link:https://gatling.io/docs/current/http/http_protocol/[`here`].
+
+However, in the scenario we are proposing, we are leveraging the link:https://github.com/GerritForge/gatling-git[`Gatling Git extension`]
+to run tests at Git protocol level.
+
+Gatling is written in Scala, but the abstraction provided by the Gatling DSL makes the scenarios implementation easy even without any Scala knowledge.
+
+Examples of scenarios can be found in the `e2e-tests` directory.
+
+=== How to run the load tests
+
+==== Prerequisites
+
+* link:https://www.scala-lang.org/download/[`Scala 2.12`]
+
+==== How to build
+
+----
+sbt compile
+----
+
+==== Setup
+
+If you are running SSH commands the private keys of the users used for testing need to go in `/tmp/ssh-keys`.
+The keys need to be generated this way (JSch won't validate them [otherwise](https://stackoverflow.com/questions/53134212/invalid-privatekey-when-using-jsch):
+
+----
+ssh-keygen -m PEM -t rsa -C "test@mail.com" -f /tmp/ssh-keys/id_rsa
+----
+
+*NOTE*: Don't forget to add the public keys for the testing user(s) to your git server
+
+==== Input file
+
+The ReplayRecordsScenario is fed by the data coming from the [src/test/resources/data/requests.json](/src/test/resources/data/requests.json) file.
+Such file contains the commands and repo used during the load test.
+Below an example:
+
+----
+[
+  {
+    "url": "ssh://admin@localhost:29418/loadtest-repo.git",
+    "cmd": "clone"
+  },
+  {
+    "url": "http://localhost:8080/loadtest-repo.git",
+    "cmd": "fetch"
+  }
+]
+----
+
+Valid commands are:
+* fetch
+* pull
+* push
+* clone
+
+==== How to use the framework
+
+Run all tests:
+----
+sbt "gatling:test"
+----
+
+Run a single test:
+----
+sbt "gatling:testOnly com.google.gerrit.scenarios.ReplayRecordsFromFeederScenario"
+----
+
+Generate the last report:
+----
+sbt "gatling:lastReport"
+----
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
+
+[scala]:
diff --git a/Documentation/pg-plugin-admin-api.txt b/Documentation/pg-plugin-admin-api.txt
index 1c724d0..084fa2c 100644
--- a/Documentation/pg-plugin-admin-api.txt
+++ b/Documentation/pg-plugin-admin-api.txt
@@ -4,7 +4,7 @@
 and provides customization of the admin menu.
 
 == addMenuLink
-`adminApi.addMenuLink(text, url, opt_external)`
+`adminApi.addMenuLink(text, url, opt_external, opt_capabilities)`
 
 Add a new link to the end of the admin navigation menu.
 
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 145af0e..5bbc9b8 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -57,8 +57,8 @@
 
 [[details]]
 --
-* `DETAILS`: Includes full name, preferred email, username and avatars
-for each account.
+* `DETAILS`: Includes full name, preferred email, username, avatars and
+status for each account.
 --
 
 [[all-emails]]
@@ -2260,9 +2260,12 @@
 See option link:rest-api-changes.html#detailed-accounts[
 DETAILED_ACCOUNTS] for change queries +
 and option link:#details[DETAILS] for account queries.
+|`avatars`         |optional|List of link:#avatar-info[AvatarInfo] +
+entities that provide information about avatar images of the account.
 |`_more_accounts`  |optional, not set if `false`|
 Whether the query would deliver more results if not limited. +
 Only set on the last account that is returned.
+|`status`          |optional|Status message of the account.
 |===============================
 
 [[account-input]]
@@ -2308,6 +2311,19 @@
 If not set or if set to an empty string, the account status is deleted.
 |=============================
 
+[[avatar-info]]
+=== AvatarInfo
+The `AccountInfo` entity contains information about an avatar image of
+an account.
+
+[options="header",cols="1,6"]
+|======================
+|Field Name|Description
+|`url`     |The URL to the avatar image.
+|`height`  |The height of the avatar image in pixels.
+|`width`   |The width of the avatar image in pixels.
+|======================
+
 [[capability-info]]
 === CapabilityInfo
 The `CapabilityInfo` entity contains information about the global
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index f958f44..d2f4d97 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -3088,6 +3088,12 @@
 The reviewer to be added to the change must be provided in the request
 body as a link:#reviewer-input[ReviewerInput] entity.
 
+Users can be moved from reviewer to CC and vice versa. This means if a
+user is added as CC that is already a reviewer on the change, the
+reviewer state of that user is updated to CC. If a user that is already
+a CC on the change is added as reviewer, the reviewer state of that
+user is updated to reviewer.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers HTTP/1.0
@@ -3131,6 +3137,12 @@
 If a group with many members is added as reviewer a confirmation may be
 required.
 
+If a group is added as CC and members of this group are already
+reviewers on the change, these members stay reviewers on the change
+(they are not downgraded to CC). However if a group is added as
+reviewer, all group members become reviewer of the change, even if they
+have been added as CC before.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers HTTP/1.0
diff --git a/WORKSPACE b/WORKSPACE
index 284ed48..83ee604 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -64,9 +64,26 @@
     urls = ["https://raw.githubusercontent.com/google/closure-compiler/35d2b3340ff23a69441f10fa3bc820691c2942f2/contrib/externs/polymer-1.0.js"],
 )
 
-load("@bazel_skylib//lib:versions.bzl", "versions")
+# Check Bazel version when invoked by Bazel directly
+load("//tools/bzl:bazelisk_version.bzl", "bazelisk_version")
 
-versions.check(minimum_bazel_version = "0.26.1")
+bazelisk_version(name = "bazelisk_version")
+
+load("@bazelisk_version//:check.bzl", "check_bazel_version")
+
+check_bazel_version()
+
+# Rules Python
+http_archive(
+    name = "rules_python",
+    sha256 = "b5bab4c47e863e0fbb77df4a40c45ca85f98f5a2826939181585644c9f31b97b",
+    strip_prefix = "rules_python-9d68f24659e8ce8b736590ba1e4418af06ec2552",
+    urls = ["https://github.com/bazelbuild/rules_python/archive/9d68f24659e8ce8b736590ba1e4418af06ec2552.tar.gz"],
+)
+
+load("@rules_python//python:repositories.bzl", "py_repositories")
+
+py_repositories()
 
 load("@io_bazel_rules_closure//closure:defs.bzl", "closure_repositories")
 
@@ -106,6 +123,17 @@
 
 gazelle_dependencies()
 
+# Protobuf rules support
+http_archive(
+    name = "rules_proto",
+    sha256 = "602e7161d9195e50246177e7c55b2f39950a9cf7366f74ed5f22fd45750cd208",
+    strip_prefix = "rules_proto-97d8af4dc474595af3900dd85cb3a29ad28cc313",
+    urls = [
+        "https://mirror.bazel.build/github.com/bazelbuild/rules_proto/archive/97d8af4dc474595af3900dd85cb3a29ad28cc313.tar.gz",
+        "https://github.com/bazelbuild/rules_proto/archive/97d8af4dc474595af3900dd85cb3a29ad28cc313.tar.gz",
+    ],
+)
+
 # Dependencies for PolyGerrit local dev server.
 go_repository(
     name = "com_github_howeyc_fsnotify",
@@ -723,7 +751,7 @@
     sha1 = "f7be08ec23c21485b9b5a1cf1654c2ec8c58168d",
 )
 
-GITILES_VERS = "0.3-3"
+GITILES_VERS = "0.3-4"
 
 GITILES_REPO = GERRIT
 
@@ -732,14 +760,14 @@
     artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS,
     attach_source = False,
     repository = GITILES_REPO,
-    sha1 = "d0bcfc6292b8d69a76156baa620ac892f68a5d23",
+    sha1 = "d1d62c9905b0cc9e066d337b33480599f430eb87",
 )
 
 maven_jar(
     name = "gitiles-servlet",
     artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS,
     repository = GITILES_REPO,
-    sha1 = "7c010f892c346fe3278df15f73b1588a110f0187",
+    sha1 = "7360cb90576a813cd1288cf853c59824fe92467e",
 )
 
 # prettify must match the version used in Gitiles
@@ -752,8 +780,8 @@
 # Keep this version of Soy synchronized with the version used in Gitiles.
 maven_jar(
     name = "soy",
-    artifact = "com.google.template:soy:2019-08-22",
-    sha1 = "d4bf390caf7aa448108a5b9ec1b51f46820438f3",
+    artifact = "com.google.template:soy:2019-09-03",
+    sha1 = "40781da0302b4b5d53006dc8bd5a432c7288d807",
 )
 
 maven_jar(
@@ -1072,18 +1100,18 @@
     sha1 = "0f5a654e4675769c716e5b387830d19b501ca191",
 )
 
-TESTCONTAINERS_VERSION = "1.12.0"
+TESTCONTAINERS_VERSION = "1.12.1"
 
 maven_jar(
     name = "testcontainers",
     artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
-    sha1 = "ac89643ce1ddde504da09172086aba0c7df10bff",
+    sha1 = "1dc8666ead914c5515d087f75ffe92629414caf6",
 )
 
 maven_jar(
     name = "testcontainers-elasticsearch",
     artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
-    sha1 = "cd9020f1803396c45ef935312bf232f9b17332b0",
+    sha1 = "2491f792627a1f15d341bfcd6dd0ea7e3541d82f",
 )
 
 maven_jar(
@@ -1119,13 +1147,13 @@
 BYTE_BUDDY_VERSION = "1.9.7"
 
 maven_jar(
-    name = "byte-buddy",
+    name = "bytebuddy",
     artifact = "net.bytebuddy:byte-buddy:" + BYTE_BUDDY_VERSION,
     sha1 = "8fea78fea6449e1738b675cb155ce8422661e237",
 )
 
 maven_jar(
-    name = "byte-buddy-agent",
+    name = "bytebuddy-agent",
     artifact = "net.bytebuddy:byte-buddy-agent:" + BYTE_BUDDY_VERSION,
     sha1 = "8e7d1b599f4943851ffea125fd9780e572727fc0",
 )
diff --git a/antlr3/BUILD b/antlr3/BUILD
index 2d3050e..549946a 100644
--- a/antlr3/BUILD
+++ b/antlr3/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//tools/bzl:genrule2.bzl", "genrule2")
 
 genrule2(
diff --git a/contrib/remove-notedb-refs.sh b/contrib/remove-notedb-refs.sh
new file mode 100755
index 0000000..3c13067
--- /dev/null
+++ b/contrib/remove-notedb-refs.sh
@@ -0,0 +1,56 @@
+# 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.
+#!/usr/bin/env bash
+
+set -e
+
+if [[ "$#" -lt "2" ]] ; then
+  cat <<EOF
+Usage: run "$0 /path/to/git/dir [project...]" or "$0 /path/to/git/dir ALL"
+
+This util script can be used in case of rollback to ReviewDB during an unsuccessful
+migration to NoteDB or simply while testing the migration process.
+
+It will remove all the refs used by NoteDB added during the migration (i.e.: change meta refs and sequence ref).
+EOF
+  exit 1
+fi
+
+GERRIT_GIT_DIR=$1
+shift
+
+ALL_PROJECTS=$@
+if [[ "$2" -eq "ALL" ]] ; then
+ ALL_PROJECTS=`find "${GERRIT_GIT_DIR}" -type d -name "*.git"`
+fi
+
+ALL_PROJECTS_ARRAY=(${ALL_PROJECTS// / })
+
+for project in "${ALL_PROJECTS_ARRAY[@]}"
+do
+    if [[ "$project" =~ /All-Users\.git$ ]]; then
+        echo "Skipping $project ..."
+    else
+        echo "Removing meta ref for $project ..."
+        cd "$project"
+        if `git show-ref meta | grep -q "/meta$"`; then
+            git show-ref meta | grep "/meta$" | cut -d' ' -f2 | xargs -L1 git update-ref -d
+        fi
+    fi
+done
+
+echo "Remove sequence ref"
+allProjectDir="$GERRIT_GIT_DIR/All-Projects.git"
+cd $allProjectDir
+git update-ref -d refs/sequences/changes
diff --git a/e2e-tests/load-tests/.gitignore b/e2e-tests/load-tests/.gitignore
new file mode 100644
index 0000000..052f424
--- /dev/null
+++ b/e2e-tests/load-tests/.gitignore
@@ -0,0 +1,16 @@
+.idea/
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+### Scala ###
+*.class
+*.log
+target
+project/target
diff --git a/e2e-tests/load-tests/build.sbt b/e2e-tests/load-tests/build.sbt
new file mode 100644
index 0000000..46a3202
--- /dev/null
+++ b/e2e-tests/load-tests/build.sbt
@@ -0,0 +1,18 @@
+import Dependencies._
+
+enablePlugins(GatlingPlugin)
+
+lazy val gatlingGitExtension = RootProject(uri("git://github.com/GerritForge/gatling-git.git"))
+lazy val root = (project in file("."))
+  .settings(
+    inThisBuild(List(
+      organization := "com.google.gerrit",
+      scalaVersion := "2.12.8",
+      version := "0.1.0-SNAPSHOT"
+    )),
+    name := "gerrit",
+    libraryDependencies ++=
+      gatling ++
+        Seq("io.gatling" % "gatling-core" % "3.1.1" ) ++
+        Seq("io.gatling" % "gatling-app" % "3.1.1" )
+  ) dependsOn(gatlingGitExtension)
diff --git a/e2e-tests/load-tests/project/Dependencies.scala b/e2e-tests/load-tests/project/Dependencies.scala
new file mode 100644
index 0000000..72d2ac2
--- /dev/null
+++ b/e2e-tests/load-tests/project/Dependencies.scala
@@ -0,0 +1,8 @@
+import sbt._
+
+object Dependencies {
+  lazy val gatling = Seq(
+    "io.gatling.highcharts" % "gatling-charts-highcharts",
+    "io.gatling" % "gatling-test-framework",
+  ).map(_ % "3.1.1" % Test)
+}
diff --git a/e2e-tests/load-tests/project/build.properties b/e2e-tests/load-tests/project/build.properties
new file mode 100644
index 0000000..0cd8b07
--- /dev/null
+++ b/e2e-tests/load-tests/project/build.properties
@@ -0,0 +1 @@
+sbt.version=1.2.3
diff --git a/e2e-tests/load-tests/project/plugins.sbt b/e2e-tests/load-tests/project/plugins.sbt
new file mode 100644
index 0000000..36cd201
--- /dev/null
+++ b/e2e-tests/load-tests/project/plugins.sbt
@@ -0,0 +1 @@
+addSbtPlugin("io.gatling" % "gatling-sbt" % "3.0.0")
diff --git a/e2e-tests/load-tests/src/test/resources/application.conf b/e2e-tests/load-tests/src/test/resources/application.conf
new file mode 100644
index 0000000..33da75d
--- /dev/null
+++ b/e2e-tests/load-tests/src/test/resources/application.conf
@@ -0,0 +1,30 @@
+http {
+  username: "default_username",
+  username: ${?GIT_HTTP_USERNAME},
+
+  password: "default_password",
+  password: ${?GIT_HTTP_PASSWORD},
+}
+
+ssh {
+  private_key_path: "/tmp/ssh-keys/id_rsa",
+  private_key_path: ${?GIT_SSH_PRIVATE_KEY_PATH},
+}
+
+tmpFiles {
+  basePath: "/tmp"
+  basePath: ${?TMP_BASE_PATH}
+}
+
+commands {
+  push {
+    numFiles: 4
+    numFiles: ${?NUM_FILES}
+    minContentLength: 100
+    minContentLength: ${?MIN_CONTENT_LEGTH}
+    maxContentLength: 10000
+    maxContentLength: ${?MAX_CONTENT_LEGTH}
+    commitPrefix: ""
+    commitPrefix: ${?COMMIT_PREFIX}
+  }
+}
diff --git a/e2e-tests/load-tests/src/test/resources/data/requests.json b/e2e-tests/load-tests/src/test/resources/data/requests.json
new file mode 100644
index 0000000..86f9bf1
--- /dev/null
+++ b/e2e-tests/load-tests/src/test/resources/data/requests.json
@@ -0,0 +1,26 @@
+[
+  {
+    "url": "ssh://admin@localhost:29418/loadtest-repo",
+    "cmd": "clone"
+  },
+  {
+    "url": "ssh://admin@localhost:29418/loadtest-repo",
+    "cmd": "pull"
+  },
+  {
+    "url": "ssh://admin@localhost:29418/loadtest-repo",
+    "cmd": "push"
+  },
+  {
+    "url": "http://localhost:8080/loadtest-repo",
+    "cmd": "clone"
+  },
+  {
+    "url": "http://localhost:8080/loadtest-repo",
+    "cmd": "pull"
+  },
+  {
+    "url": "http://localhost:8080/loadtest-repo",
+    "cmd": "push"
+  }
+]
diff --git a/e2e-tests/load-tests/src/test/resources/gatling.conf b/e2e-tests/load-tests/src/test/resources/gatling.conf
new file mode 100644
index 0000000..94c371b
--- /dev/null
+++ b/e2e-tests/load-tests/src/test/resources/gatling.conf
@@ -0,0 +1,128 @@
+#########################
+# Gatling Configuration #
+#########################
+
+# This file contains all the settings configurable for Gatling with their default values
+
+gatling {
+  core {
+    #outputDirectoryBaseName = "" # The prefix for each simulation result folder (then suffixed by the report generation timestamp)
+    #runDescription = ""          # The description for this simulation run, displayed in each report
+    #encoding = "utf-8"           # Encoding to use throughout Gatling for file and string manipulation
+    #simulationClass = ""         # The FQCN of the simulation to run (when used in conjunction with noReports, the simulation for which assertions will be validated)
+    #elFileBodiesCacheMaxCapacity = 200        # Cache size for request body EL templates, set to 0 to disable
+    #rawFileBodiesCacheMaxCapacity = 200       # Cache size for request body Raw templates, set to 0 to disable
+    #rawFileBodiesInMemoryMaxSize = 1000       # Below this limit, raw file bodies will be cached in memory
+    #pebbleFileBodiesCacheMaxCapacity = 200    # Cache size for request body Peeble templates, set to 0 to disable
+    #shutdownTimeout = 5000                    # Milliseconds to wait for the actor system to shutdown
+    extract {
+      regex {
+        #cacheMaxCapacity = 200 # Cache size for the compiled regexes, set to 0 to disable caching
+      }
+      xpath {
+        #cacheMaxCapacity = 200 # Cache size for the compiled XPath queries,  set to 0 to disable caching
+      }
+      jsonPath {
+        #cacheMaxCapacity = 200 # Cache size for the compiled jsonPath queries, set to 0 to disable caching
+        #preferJackson = false  # When set to true, prefer Jackson over Boon for JSON-related operations
+      }
+      css {
+        #cacheMaxCapacity = 200 # Cache size for the compiled CSS selectors queries,  set to 0 to disable caching
+      }
+    }
+    directory {
+      simulations = "./src/test/scala"
+      #simulations = user-files/simulations # Directory where simulation classes are located (for bundle packaging only)
+      resources = "./src/test/resources/data"     # Directory where resources, such as feeder files and request bodies are located (for bundle packaging only)
+      #reportsOnly = ""                     # If set, name of report folder to look for in order to generate its report
+      binaries = "./target/scala-2.12/classes"                        # If set, name of the folder where compiles classes are located: Defaults to GATLING_HOME/target.
+      #results = results                    # Name of the folder where all reports folder are located
+    }
+  }
+  charting {
+    #noReports = false       # When set to true, don't generate HTML reports
+    #maxPlotPerSeries = 1000 # Number of points per graph in Gatling reports
+    #useGroupDurationMetric = false  # Switch group timings from cumulated response time to group duration.
+    indicators {
+      #lowerBound = 800      # Lower bound for the requests' response time to track in the reports and the console summary
+      #higherBound = 1200    # Higher bound for the requests' response time to track in the reports and the console summary
+      #percentile1 = 50      # Value for the 1st percentile to track in the reports, the console summary and Graphite
+      #percentile2 = 75      # Value for the 2nd percentile to track in the reports, the console summary and Graphite
+      #percentile3 = 95      # Value for the 3rd percentile to track in the reports, the console summary and Graphite
+      #percentile4 = 99      # Value for the 4th percentile to track in the reports, the console summary and Graphite
+    }
+  }
+  http {
+    #fetchedCssCacheMaxCapacity = 200          # Cache size for CSS parsed content, set to 0 to disable
+    #fetchedHtmlCacheMaxCapacity = 200         # Cache size for HTML parsed content, set to 0 to disable
+    #perUserCacheMaxCapacity = 200             # Per virtual user cache size, set to 0 to disable
+    #warmUpUrl = "https://gatling.io"           # The URL to use to warm-up the HTTP stack (blank means disabled)
+    #enableGA = true                           # Very light Google Analytics, please support
+    ssl {
+      keyStore {
+        #type = ""      # Type of SSLContext's KeyManagers store
+        #file = ""      # Location of SSLContext's KeyManagers store
+        #password = ""  # Password for SSLContext's KeyManagers store
+        #algorithm = "" # Algorithm used SSLContext's KeyManagers store
+      }
+      trustStore {
+        #type = ""      # Type of SSLContext's TrustManagers store
+        #file = ""      # Location of SSLContext's TrustManagers store
+        #password = ""  # Password for SSLContext's TrustManagers store
+        #algorithm = "" # Algorithm used by SSLContext's TrustManagers store
+      }
+    }
+    ahc {
+      #connectTimeout = 10000                              # Timeout in millis for establishing a TCP socket
+      #handshakeTimeout = 10000                            # Timeout in millis for performing TLS handshake
+      #pooledConnectionIdleTimeout = 60000                 # Timeout in millis for a connection to stay idle in the pool
+      #maxRetry = 2                                        # Number of times that a request should be tried again
+      #requestTimeout = 60000                              # Timeout in millis for performing an HTTP request
+      #enableSni = true                                    # When set to true, enable Server Name indication (SNI)
+      #enableHostnameVerification = false                  # When set to true, enable hostname verification: SSLEngine.setHttpsEndpointIdentificationAlgorithm("HTTPS")
+      #useInsecureTrustManager = true                      # Use an insecure TrustManager that trusts all server certificates
+      #filterInsecureCipherSuites = true                   # Turn to false to not filter out insecure and weak cipher suites
+      #sslEnabledProtocols = [TLSv1.2, TLSv1.1, TLSv1]     # Array of enabled protocols for HTTPS, if empty use the JDK defaults
+      #sslEnabledCipherSuites = []                         # Array of enabled cipher suites for HTTPS, if empty use the AHC defaults
+      #sslSessionCacheSize = 0                             # SSLSession cache size, set to 0 to use JDK's default
+      #sslSessionTimeout = 0                               # SSLSession timeout in seconds, set to 0 to use JDK's default (24h)
+      #disableSslSessionResumption = false                 # if true, SSLSessions won't be resumed
+      #useOpenSsl = true                                   # if OpenSSL should be used instead of JSSE
+      #useNativeTransport = false                          # if native transport should be used instead of Java NIO (requires netty-transport-native-epoll, currently Linux only)
+      #enableZeroCopy = true                               # if zero-copy upload should be used if possible
+      #tcpNoDelay = true
+      #soReuseAddress = false
+      #allocator = "pooled"                            # switch to unpooled for unpooled ByteBufAllocator
+      #maxThreadLocalCharBufferSize = 200000           # Netty's default is 16k
+    }
+    dns {
+      #queryTimeout = 5000                             # Timeout in millis of each DNS query in millis
+      #maxQueriesPerResolve = 6                        # Maximum allowed number of DNS queries for a given name resolution
+    }
+  }
+  jms {
+    #replyTimeoutScanPeriod = 1000  # scan period for timedout reply messages
+  }
+  data {
+    #writers = [console, file]      # The list of DataWriters to which Gatling write simulation data (currently supported : console, file, graphite, jdbc)
+    console {
+      #light = false                # When set to true, displays a light version without detailed request stats
+      #writePeriod = 5              # Write interval, in seconds
+    }
+    file {
+      #bufferSize = 8192            # FileDataWriter's internal data buffer size, in bytes
+    }
+    leak {
+      #noActivityTimeout = 30  # Period, in seconds, for which Gatling may have no activity before considering a leak may be happening
+    }
+    graphite {
+      #light = false              # only send the all* stats
+      #host = "localhost"         # The host where the Carbon server is located
+      #port = 2003                # The port to which the Carbon server listens to (2003 is default for plaintext, 2004 is default for pickle)
+      #protocol = "tcp"           # The protocol used to send data to Carbon (currently supported : "tcp", "udp")
+      #rootPathPrefix = "gatling" # The common prefix of all metrics sent to Graphite
+      #bufferSize = 8192          # Internal data buffer size, in bytes
+      #writePeriod = 1            # Write period, in seconds
+    }
+  }
+}
diff --git a/e2e-tests/load-tests/src/test/resources/hooks/commit-msg b/e2e-tests/load-tests/src/test/resources/hooks/commit-msg
new file mode 100644
index 0000000..b05a671
--- /dev/null
+++ b/e2e-tests/load-tests/src/test/resources/hooks/commit-msg
@@ -0,0 +1,43 @@
+#!/bin/sh
+#
+# Part of Gerrit Code Review (https://www.gerritcodereview.com/)
+#
+# Copyright (C) 2009 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.
+
+# avoid [[ which is not POSIX sh.
+if test "$#" != 1 ; then
+  echo "$0 requires an argument."
+  exit 1
+fi
+
+if test ! -f "$1" ; then
+  echo "file does not exist: $1"
+  exit 1
+fi
+
+if test ! -s "$1" ; then
+  echo "file is empty: $1"
+  exit 1
+fi
+
+# $RANDOM will be undefined if not using bash, so don't use set -u
+random=$( (whoami ; hostname ; date; cat $1 ; echo $RANDOM) | git hash-object --stdin)
+dest="$1.tmp.${random}"
+
+# Avoid the --in-place option which only appeared in Git 2.8
+# Avoid the --if-exists option which only appeared in Git 2.15
+cat "$1" \
+| git -c trailer.ifexists=doNothing interpret-trailers --trailer "Change-Id: I${random}" > "${dest}" \
+&& mv "${dest}" "$1"
diff --git a/e2e-tests/load-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala b/e2e-tests/load-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
new file mode 100644
index 0000000..c0eab39
--- /dev/null
+++ b/e2e-tests/load-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
@@ -0,0 +1,72 @@
+// 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.scenarios
+
+import com.github.barbasa.gatling.git.protocol.GitProtocol
+import com.github.barbasa.gatling.git.request.builder.GitRequestBuilder
+import io.gatling.core.Predef._
+import io.gatling.core.structure.ScenarioBuilder
+import java.io._
+
+import com.github.barbasa.gatling.git.{
+  GatlingGitConfiguration,
+  GitRequestSession
+}
+import org.apache.commons.io.FileUtils
+
+import scala.concurrent.duration._
+import org.eclipse.jgit.hooks._
+
+class ReplayRecordsFromFeederScenario extends Simulation {
+
+  val gitProtocol = GitProtocol()
+  implicit val conf = GatlingGitConfiguration()
+  implicit val postMessageHook: Option[String] = Some(
+    s"hooks/${CommitMsgHook.NAME}")
+
+  val feeder = jsonFile("data/requests.json").circular
+
+  val replayCallsScenario: ScenarioBuilder =
+    scenario("Git commands")
+      .repeat(10000) {
+        feed(feeder)
+          .exec(new GitRequestBuilder(GitRequestSession("${cmd}", "${url}")))
+      }
+
+  setUp(
+    replayCallsScenario.inject(
+      nothingFor(4 seconds),
+      atOnceUsers(10),
+      rampUsers(10) during (5 seconds),
+      constantUsersPerSec(20) during (15 seconds),
+      constantUsersPerSec(20) during (15 seconds) randomized
+    ))
+    .protocols(gitProtocol)
+    .maxDuration(60 seconds)
+
+  after {
+    try {
+      //After is often called too early. Some retries should be implemented.
+      Thread.sleep(5000)
+      FileUtils.deleteDirectory(new File(conf.tmpBasePath))
+    } catch {
+      case e: IOException => {
+        System.err.println(
+          "Unable to delete temporary directory: " + conf.tmpBasePath)
+        e.printStackTrace
+      }
+    }
+  }
+}
diff --git a/java/BUILD b/java/BUILD
index 4fc4d79..77611e4 100644
--- a/java/BUILD
+++ b/java/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_binary", "java_library")
+
 java_binary(
     name = "gerrit-main-class",
     main_class = "Main",
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index 768c340..eca7ea6 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_binary", "java_library")
 load("//tools/bzl:java.bzl", "java_library2")
 load("//tools/bzl:javadoc.bzl", "java_doc")
 
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index a48a278..ea63d73 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -259,6 +259,8 @@
           // Silence non-critical messages from JGit.
           .put("org.eclipse.jgit.transport.PacketLineIn", Level.WARN)
           .put("org.eclipse.jgit.transport.PacketLineOut", Level.WARN)
+          .put("org.eclipse.jgit.internal.storage.file.FileSnapshot", Level.WARN)
+          .put("org.eclipse.jgit.util.FS", Level.WARN)
           .build();
 
   private static boolean forceLocalDisk() {
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/BUILD b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
index 3215a9c..1ef2931 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/BUILD
+++ b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 package(default_testonly = 1)
 
 java_library(
diff --git a/java/com/google/gerrit/asciidoctor/BUILD b/java/com/google/gerrit/asciidoctor/BUILD
index f5178a0..94ec20d 100644
--- a/java/com/google/gerrit/asciidoctor/BUILD
+++ b/java/com/google/gerrit/asciidoctor/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_binary", "java_library")
+
 java_binary(
     name = "asciidoc",
     main_class = "com.google.gerrit.asciidoctor.AsciiDoctor",
diff --git a/java/com/google/gerrit/common/BUILD b/java/com/google/gerrit/common/BUILD
index b35b8bf..113ff53 100644
--- a/java/com/google/gerrit/common/BUILD
+++ b/java/com/google/gerrit/common/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 ANNOTATIONS = [
     "Nullable.java",
     "UsedAt.java",
diff --git a/java/com/google/gerrit/common/data/testing/BUILD b/java/com/google/gerrit/common/data/testing/BUILD
index 32815d5..8ab01de 100644
--- a/java/com/google/gerrit/common/data/testing/BUILD
+++ b/java/com/google/gerrit/common/data/testing/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "common-data-test-util",
     testonly = True,
diff --git a/java/com/google/gerrit/elasticsearch/BUILD b/java/com/google/gerrit/elasticsearch/BUILD
index f919aad..a9b145b 100644
--- a/java/com/google/gerrit/elasticsearch/BUILD
+++ b/java/com/google/gerrit/elasticsearch/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "elasticsearch",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/exceptions/BUILD b/java/com/google/gerrit/exceptions/BUILD
index 50bf883..e08c3fd 100644
--- a/java/com/google/gerrit/exceptions/BUILD
+++ b/java/com/google/gerrit/exceptions/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "exceptions",
     srcs = glob(["*.java"]),
diff --git a/java/com/google/gerrit/extensions/BUILD b/java/com/google/gerrit/extensions/BUILD
index b69d2c8..0022584 100644
--- a/java/com/google/gerrit/extensions/BUILD
+++ b/java/com/google/gerrit/extensions/BUILD
@@ -1,5 +1,6 @@
-load("//lib/jgit:jgit.bzl", "JGIT_DOC_URL")
+load("@rules_java//java:defs.bzl", "java_binary", "java_library")
 load("//lib:guava.bzl", "GUAVA_DOC_URL")
+load("//lib/jgit:jgit.bzl", "JGIT_DOC_URL")
 load("//tools/bzl:javadoc.bzl", "java_doc")
 
 java_binary(
diff --git a/java/com/google/gerrit/extensions/common/testing/BUILD b/java/com/google/gerrit/extensions/common/testing/BUILD
index 7092d21..9cecb66 100644
--- a/java/com/google/gerrit/extensions/common/testing/BUILD
+++ b/java/com/google/gerrit/extensions/common/testing/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "common-test-util",
     testonly = True,
diff --git a/java/com/google/gerrit/extensions/restapi/testing/BUILD b/java/com/google/gerrit/extensions/restapi/testing/BUILD
index 3417cae2..4c44d2a 100644
--- a/java/com/google/gerrit/extensions/restapi/testing/BUILD
+++ b/java/com/google/gerrit/extensions/restapi/testing/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "restapi-test-util",
     testonly = True,
diff --git a/java/com/google/gerrit/git/BUILD b/java/com/google/gerrit/git/BUILD
index fc146dc..5ece37a 100644
--- a/java/com/google/gerrit/git/BUILD
+++ b/java/com/google/gerrit/git/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "git",
     srcs = glob(["*.java"]),
diff --git a/java/com/google/gerrit/git/testing/BUILD b/java/com/google/gerrit/git/testing/BUILD
index 497510d..13fddc1 100644
--- a/java/com/google/gerrit/git/testing/BUILD
+++ b/java/com/google/gerrit/git/testing/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 package(default_testonly = True)
 
 java_library(
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index 49806cf..d45a86d 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "gpg",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/gpg/testing/BUILD b/java/com/google/gerrit/gpg/testing/BUILD
index 0282d3a..b227dd5 100644
--- a/java/com/google/gerrit/gpg/testing/BUILD
+++ b/java/com/google/gerrit/gpg/testing/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "gpg-test-util",
     testonly = True,
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index f86b35d5..c4b0314 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "httpd",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/httpd/auth/oauth/BUILD b/java/com/google/gerrit/httpd/auth/oauth/BUILD
index dd3e5fc..b74a65a 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/BUILD
+++ b/java/com/google/gerrit/httpd/auth/oauth/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "oauth",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/httpd/auth/openid/BUILD b/java/com/google/gerrit/httpd/auth/openid/BUILD
index edd12cc..ad9d3aa 100644
--- a/java/com/google/gerrit/httpd/auth/openid/BUILD
+++ b/java/com/google/gerrit/httpd/auth/openid/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "openid",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/httpd/init/BUILD b/java/com/google/gerrit/httpd/init/BUILD
index df072b2..09772fd 100644
--- a/java/com/google/gerrit/httpd/init/BUILD
+++ b/java/com/google/gerrit/httpd/init/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "init",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/index/BUILD b/java/com/google/gerrit/index/BUILD
index 7fcf342..5b6aae5 100644
--- a/java/com/google/gerrit/index/BUILD
+++ b/java/com/google/gerrit/index/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 QUERY_PARSE_EXCEPTION_SRCS = [
     "query/QueryParseException.java",
     "query/QueryRequiresAuthException.java",
diff --git a/java/com/google/gerrit/index/project/BUILD b/java/com/google/gerrit/index/project/BUILD
index f32d8c0..2c460fd 100644
--- a/java/com/google/gerrit/index/project/BUILD
+++ b/java/com/google/gerrit/index/project/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "project",
     srcs = glob(["*.java"]),
diff --git a/java/com/google/gerrit/index/query/testing/BUILD b/java/com/google/gerrit/index/query/testing/BUILD
index ee346a8..1785f49 100644
--- a/java/com/google/gerrit/index/query/testing/BUILD
+++ b/java/com/google/gerrit/index/query/testing/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 package(
     default_testonly = True,
     default_visibility = ["//visibility:public"],
diff --git a/java/com/google/gerrit/jgit/BUILD b/java/com/google/gerrit/jgit/BUILD
index 2387614..e67ebfe 100644
--- a/java/com/google/gerrit/jgit/BUILD
+++ b/java/com/google/gerrit/jgit/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "jgit",
     srcs = [
diff --git a/java/com/google/gerrit/json/BUILD b/java/com/google/gerrit/json/BUILD
index 030dddcb..439f23f 100644
--- a/java/com/google/gerrit/json/BUILD
+++ b/java/com/google/gerrit/json/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "json",
     srcs = glob(["*.java"]),
diff --git a/java/com/google/gerrit/launcher/BUILD b/java/com/google/gerrit/launcher/BUILD
index bac0c53..15fa0ce 100644
--- a/java/com/google/gerrit/launcher/BUILD
+++ b/java/com/google/gerrit/launcher/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 # NOTE: GerritLauncher must be a single, self-contained class. Do not add any
 # additional srcs or deps to this rule.
 java_library(
diff --git a/java/com/google/gerrit/lifecycle/BUILD b/java/com/google/gerrit/lifecycle/BUILD
index 7ba6123..a3f3d81 100644
--- a/java/com/google/gerrit/lifecycle/BUILD
+++ b/java/com/google/gerrit/lifecycle/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "lifecycle",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/lucene/BUILD b/java/com/google/gerrit/lucene/BUILD
index fa4c923..40b2548 100644
--- a/java/com/google/gerrit/lucene/BUILD
+++ b/java/com/google/gerrit/lucene/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 QUERY_BUILDER = ["QueryBuilder.java"]
 
 java_library(
diff --git a/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java b/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java
index 75e03e3..f6b2f0e 100644
--- a/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java
+++ b/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.config.ConfigUtil;
 import org.apache.lucene.analysis.CharArraySet;
 import org.apache.lucene.analysis.standard.StandardAnalyzer;
+import org.apache.lucene.index.ConcurrentMergeScheduler;
 import org.apache.lucene.index.IndexWriterConfig;
 import org.apache.lucene.index.IndexWriterConfig.OpenMode;
 import org.eclipse.jgit.lib.Config;
@@ -42,6 +43,21 @@
         new IndexWriterConfig(analyzer)
             .setOpenMode(OpenMode.CREATE_OR_APPEND)
             .setCommitOnClose(true);
+
+    int maxMergeCount = cfg.getInt("index", name, "maxMergeCount", -1);
+    int maxThreadCount = cfg.getInt("index", name, "maxThreadCount", -1);
+    boolean enableAutoIOThrottle = cfg.getBoolean("index", name, "enableAutoIOThrottle", true);
+    if (maxMergeCount != -1 || maxThreadCount != -1 || !enableAutoIOThrottle) {
+      ConcurrentMergeScheduler mergeScheduler = new ConcurrentMergeScheduler();
+      if (maxMergeCount != -1 || maxThreadCount != -1) {
+        mergeScheduler.setMaxMergesAndThreads(maxMergeCount, maxThreadCount);
+      }
+      if (!enableAutoIOThrottle) {
+        mergeScheduler.disableAutoIOThrottle();
+      }
+      luceneConfig.setMergeScheduler(mergeScheduler);
+    }
+
     double m = 1 << 20;
     luceneConfig.setRAMBufferSizeMB(
         cfg.getLong(
diff --git a/java/com/google/gerrit/mail/BUILD b/java/com/google/gerrit/mail/BUILD
index 90bb82c..6be5f0e 100644
--- a/java/com/google/gerrit/mail/BUILD
+++ b/java/com/google/gerrit/mail/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "mail",
     srcs = glob(["*.java"]),
diff --git a/java/com/google/gerrit/metrics/BUILD b/java/com/google/gerrit/metrics/BUILD
index 68b29a4..9cc7654 100644
--- a/java/com/google/gerrit/metrics/BUILD
+++ b/java/com/google/gerrit/metrics/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "metrics",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/metrics/dropwizard/BUILD b/java/com/google/gerrit/metrics/dropwizard/BUILD
index 9adb375..4b3859f 100644
--- a/java/com/google/gerrit/metrics/dropwizard/BUILD
+++ b/java/com/google/gerrit/metrics/dropwizard/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "dropwizard",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index 02c083c..ea6e9b7 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 # TODO(davido): This indirection doesn't avoid unwanted depdencies
 # in acceptance-framework and should be removed. Instead, provided_deps
 # should be used, once https://github.com/bazelbuild/bazel/issues/1402
diff --git a/java/com/google/gerrit/pgm/http/BUILD b/java/com/google/gerrit/pgm/http/BUILD
index 838c614..34115ae 100644
--- a/java/com/google/gerrit/pgm/http/BUILD
+++ b/java/com/google/gerrit/pgm/http/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "http",
     visibility = ["//visibility:public"],
diff --git a/java/com/google/gerrit/pgm/http/jetty/BUILD b/java/com/google/gerrit/pgm/http/jetty/BUILD
index a6a13dc..ea3afe1 100644
--- a/java/com/google/gerrit/pgm/http/jetty/BUILD
+++ b/java/com/google/gerrit/pgm/http/jetty/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "jetty",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/pgm/init/BUILD b/java/com/google/gerrit/pgm/init/BUILD
index b2a4d72..eb0d49e 100644
--- a/java/com/google/gerrit/pgm/init/BUILD
+++ b/java/com/google/gerrit/pgm/init/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "init",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/pgm/init/api/BUILD b/java/com/google/gerrit/pgm/init/api/BUILD
index 5b07fc6..19203fc 100644
--- a/java/com/google/gerrit/pgm/init/api/BUILD
+++ b/java/com/google/gerrit/pgm/init/api/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "api",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/pgm/util/BUILD b/java/com/google/gerrit/pgm/util/BUILD
index ffd1cbd..94798f7 100644
--- a/java/com/google/gerrit/pgm/util/BUILD
+++ b/java/com/google/gerrit/pgm/util/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "util",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/prettify/BUILD b/java/com/google/gerrit/prettify/BUILD
index 88b5b60..76afbe7 100644
--- a/java/com/google/gerrit/prettify/BUILD
+++ b/java/com/google/gerrit/prettify/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "server",
     srcs = glob(["common/**/*.java"]),
diff --git a/java/com/google/gerrit/proto/BUILD b/java/com/google/gerrit/proto/BUILD
index 4f05bf6..98558c5 100644
--- a/java/com/google/gerrit/proto/BUILD
+++ b/java/com/google/gerrit/proto/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "proto",
     srcs = ["Protos.java"],
diff --git a/java/com/google/gerrit/proto/testing/BUILD b/java/com/google/gerrit/proto/testing/BUILD
index 48115ff..acfa8f0 100644
--- a/java/com/google/gerrit/proto/testing/BUILD
+++ b/java/com/google/gerrit/proto/testing/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 package(default_testonly = True)
 
 java_library(
diff --git a/java/com/google/gerrit/reviewdb/BUILD b/java/com/google/gerrit/reviewdb/BUILD
index 8c286ce..838aee8 100644
--- a/java/com/google/gerrit/reviewdb/BUILD
+++ b/java/com/google/gerrit/reviewdb/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 package(
     default_visibility = ["//visibility:public"],
 )
diff --git a/java/com/google/gerrit/reviewdb/client/RefNames.java b/java/com/google/gerrit/reviewdb/client/RefNames.java
index 3854310..115d6c2 100644
--- a/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -128,6 +128,11 @@
     return shard(id.changeId().get(), r).append('/').append(id.get()).toString();
   }
 
+  public static String changeRefPrefix(Change.Id id) {
+    StringBuilder r = newStringBuilder().append(REFS_CHANGES);
+    return shard(id.get(), r).append('/').toString();
+  }
+
   public static String robotCommentsRef(Change.Id id) {
     StringBuilder r = newStringBuilder().append(REFS_CHANGES);
     return shard(id.get(), r).append(ROBOT_COMMENTS_SUFFIX).toString();
diff --git a/java/com/google/gerrit/server/ApprovalsUtil.java b/java/com/google/gerrit/server/ApprovalsUtil.java
index 9befb46..c4f13f1 100644
--- a/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -239,17 +239,28 @@
    * @param notes change notes.
    * @param update change update.
    * @param wantCCs accounts to CC.
+   * @param keepExistingReviewers whether provided accounts that are already reviewer should be kept
+   *     as reviewer or be downgraded to CC
    * @return whether a change was made.
    */
   public Collection<Account.Id> addCcs(
-      ChangeNotes notes, ChangeUpdate update, Collection<Account.Id> wantCCs) {
-    return addCcs(update, wantCCs, notes.load().getReviewers());
+      ChangeNotes notes,
+      ChangeUpdate update,
+      Collection<Account.Id> wantCCs,
+      boolean keepExistingReviewers) {
+    return addCcs(update, wantCCs, notes.load().getReviewers(), keepExistingReviewers);
   }
 
   private Collection<Account.Id> addCcs(
-      ChangeUpdate update, Collection<Account.Id> wantCCs, ReviewerSet existingReviewers) {
+      ChangeUpdate update,
+      Collection<Account.Id> wantCCs,
+      ReviewerSet existingReviewers,
+      boolean keepExistingReviewers) {
     Set<Account.Id> need = new LinkedHashSet<>(wantCCs);
-    need.removeAll(existingReviewers.all());
+    need.removeAll(existingReviewers.byState(CC));
+    if (keepExistingReviewers) {
+      need.removeAll(existingReviewers.byState(REVIEWER));
+    }
     need.removeAll(update.getReviewers().keySet());
     for (Account.Id account : need) {
       update.putReviewer(account, CC);
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 6d4dfcf..0992294 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//tools/bzl:javadoc.bzl", "java_doc")
 
 CONSTANTS_SRC = [
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index af8d8b0..fe386ee 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -59,7 +59,7 @@
     return new CacheModule() {
       @Override
       protected void configure() {
-        cache(BYID_NAME, Account.Id.class, new TypeLiteral<Optional<AccountState>>() {})
+        cache(BYID_NAME, Account.Id.class, new TypeLiteral<AccountState>() {})
             .loader(ByIdLoader.class);
 
         bind(AccountCacheImpl.class);
@@ -69,13 +69,13 @@
   }
 
   private final ExternalIds externalIds;
-  private final LoadingCache<Account.Id, Optional<AccountState>> byId;
+  private final LoadingCache<Account.Id, AccountState> byId;
   private final ExecutorService executor;
 
   @Inject
   AccountCacheImpl(
       ExternalIds externalIds,
-      @Named(BYID_NAME) LoadingCache<Account.Id, Optional<AccountState>> byId,
+      @Named(BYID_NAME) LoadingCache<Account.Id, AccountState> byId,
       @FanOutExecutor ExecutorService executor) {
     this.externalIds = externalIds;
     this.byId = byId;
@@ -85,9 +85,11 @@
   @Override
   public AccountState getEvenIfMissing(Account.Id accountId) {
     try {
-      return byId.get(accountId).orElse(missing(accountId));
+      return byId.get(accountId);
     } catch (ExecutionException e) {
-      logger.atWarning().withCause(e).log("Cannot load AccountState for %s", accountId);
+      if (!(e.getCause() instanceof AccountNotFoundException)) {
+        logger.atWarning().withCause(e).log("Cannot load AccountState for %s", accountId);
+      }
       return missing(accountId);
     }
   }
@@ -95,9 +97,11 @@
   @Override
   public Optional<AccountState> get(Account.Id accountId) {
     try {
-      return byId.get(accountId);
+      return Optional.ofNullable(byId.get(accountId));
     } catch (ExecutionException e) {
-      logger.atWarning().withCause(e).log("Cannot load AccountState for ID %s", accountId);
+      if (!(e.getCause() instanceof AccountNotFoundException)) {
+        logger.atWarning().withCause(e).log("Cannot load AccountState for %s", accountId);
+      }
       return Optional.empty();
     }
   }
@@ -107,10 +111,10 @@
     Map<Account.Id, AccountState> accountStates = new HashMap<>(accountIds.size());
     List<Callable<Optional<AccountState>>> callables = new ArrayList<>();
     for (Account.Id accountId : accountIds) {
-      Optional<AccountState> state = byId.getIfPresent(accountId);
+      AccountState state = byId.getIfPresent(accountId);
       if (state != null) {
         // The value is in-memory, so we just get the state
-        state.ifPresent(s -> accountStates.put(accountId, s));
+        accountStates.put(accountId, state);
       } else {
         // Queue up a callable so that we can load accounts in parallel
         callables.add(() -> get(accountId));
@@ -170,7 +174,7 @@
     return AccountState.forAccount(account.build());
   }
 
-  static class ByIdLoader extends CacheLoader<Account.Id, Optional<AccountState>> {
+  static class ByIdLoader extends CacheLoader<Account.Id, AccountState> {
     private final Accounts accounts;
 
     @Inject
@@ -179,12 +183,23 @@
     }
 
     @Override
-    public Optional<AccountState> load(Account.Id who) throws Exception {
+    public AccountState load(Account.Id who) throws Exception {
       try (TraceTimer timer =
           TraceContext.newTimer(
               "Loading account", Metadata.builder().accountId(who.get()).build())) {
-        return accounts.get(who);
+        return accounts
+            .get(who)
+            .orElseThrow(() -> new AccountNotFoundException(who + " not found"));
       }
     }
   }
+
+  /** Signals that the account was not found in the primary storage. */
+  private static class AccountNotFoundException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    public AccountNotFoundException(String message) {
+      super(message);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/testing/BUILD b/java/com/google/gerrit/server/account/externalids/testing/BUILD
index ec98ec8..6e59d22 100644
--- a/java/com/google/gerrit/server/account/externalids/testing/BUILD
+++ b/java/com/google/gerrit/server/account/externalids/testing/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "testing",
     testonly = 1,
diff --git a/java/com/google/gerrit/server/api/BUILD b/java/com/google/gerrit/server/api/BUILD
index b9e26de..459c16a 100644
--- a/java/com/google/gerrit/server/api/BUILD
+++ b/java/com/google/gerrit/server/api/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "api",
     srcs = glob(
diff --git a/java/com/google/gerrit/server/audit/BUILD b/java/com/google/gerrit/server/audit/BUILD
index 71cd3a1..5c2a40a 100644
--- a/java/com/google/gerrit/server/audit/BUILD
+++ b/java/com/google/gerrit/server/audit/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "audit",
     srcs = glob(
diff --git a/java/com/google/gerrit/server/cache/h2/BUILD b/java/com/google/gerrit/server/cache/h2/BUILD
index f85b498..79baefc 100644
--- a/java/com/google/gerrit/server/cache/h2/BUILD
+++ b/java/com/google/gerrit/server/cache/h2/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "h2",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/server/cache/mem/BUILD b/java/com/google/gerrit/server/cache/mem/BUILD
index 4106714..eb0695e 100644
--- a/java/com/google/gerrit/server/cache/mem/BUILD
+++ b/java/com/google/gerrit/server/cache/mem/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "mem",
     srcs = glob(["*.java"]),
diff --git a/java/com/google/gerrit/server/cache/serialize/BUILD b/java/com/google/gerrit/server/cache/serialize/BUILD
index a3a2054..3547605 100644
--- a/java/com/google/gerrit/server/cache/serialize/BUILD
+++ b/java/com/google/gerrit/server/cache/serialize/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "serialize",
     srcs = glob(["*.java"]),
diff --git a/java/com/google/gerrit/server/cache/testing/BUILD b/java/com/google/gerrit/server/cache/testing/BUILD
index 16cbe17..09f698c 100644
--- a/java/com/google/gerrit/server/cache/testing/BUILD
+++ b/java/com/google/gerrit/server/cache/testing/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 package(default_testonly = True)
 
 java_library(
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
index a8ebcb2..15d8c60 100644
--- a/java/com/google/gerrit/server/change/AddReviewersOp.java
+++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -64,10 +64,14 @@
      * @param accountIds account IDs to add.
      * @param addresses email addresses to add.
      * @param state resulting reviewer state.
+     * @param forGroup whether this reviewer addition adds accounts for a group
      * @return batch update operation.
      */
     AddReviewersOp create(
-        Set<Account.Id> accountIds, Collection<Address> addresses, ReviewerState state);
+        Set<Account.Id> accountIds,
+        Collection<Address> addresses,
+        ReviewerState state,
+        boolean forGroup);
   }
 
   @AutoValue
@@ -107,6 +111,7 @@
   private final Set<Account.Id> accountIds;
   private final Collection<Address> addresses;
   private final ReviewerState state;
+  private final boolean forGroup;
 
   // Unlike addedCCs, addedReviewers is a PatchSetApproval because the AddReviewerResult returned
   // via the REST API is supposed to include vote information.
@@ -130,7 +135,8 @@
       AddReviewersEmail addReviewersEmail,
       @Assisted Set<Account.Id> accountIds,
       @Assisted Collection<Address> addresses,
-      @Assisted ReviewerState state) {
+      @Assisted ReviewerState state,
+      @Assisted boolean forGroup) {
     checkArgument(state == REVIEWER || state == CC, "must be %s or %s: %s", REVIEWER, CC, state);
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
@@ -142,6 +148,7 @@
     this.accountIds = accountIds;
     this.addresses = addresses;
     this.state = state;
+    this.forGroup = forGroup;
   }
 
   // TODO(dborowitz): This mutable setter is ugly, but a) it's less ugly than adding boolean args
@@ -162,7 +169,7 @@
       if (state == CC) {
         addedCCs =
             approvalsUtil.addCcs(
-                ctx.getNotes(), ctx.getUpdate(change.currentPatchSetId()), accountIds);
+                ctx.getNotes(), ctx.getUpdate(change.currentPatchSetId()), accountIds, forGroup);
       } else {
         addedReviewers =
             approvalsUtil.addReviewers(
@@ -174,12 +181,11 @@
       }
     }
 
-    ImmutableList<Address> addressesToAdd = ImmutableList.of();
     ReviewerStateInternal internalState = ReviewerStateInternal.fromReviewerState(state);
 
     // TODO(dborowitz): This behavior should live in ApprovalsUtil or something, like addCcs does.
     ImmutableSet<Address> existing = ctx.getNotes().getReviewersByEmail().byState(internalState);
-    addressesToAdd =
+    ImmutableList<Address> addressesToAdd =
         addresses.stream().filter(a -> !existing.contains(a)).collect(toImmutableList());
 
     if (state == CC) {
diff --git a/java/com/google/gerrit/server/change/DeleteChangeOp.java b/java/com/google/gerrit/server/change/DeleteChangeOp.java
index 37ac713..bc49ecf 100644
--- a/java/com/google/gerrit/server/change/DeleteChangeOp.java
+++ b/java/com/google/gerrit/server/change/DeleteChangeOp.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.flogger.LazyArgs.lazy;
+
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -37,6 +40,8 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 public class DeleteChangeOp implements BatchUpdateOp {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     DeleteChangeOp create(Change.Id id);
   }
@@ -73,6 +78,17 @@
     // still part of the database.
     cleanUpReferences(id);
 
+    logger.atFine().log(
+        "Deleting change %s, current patch set %d is commit %s",
+        id,
+        ctx.getChange().currentPatchSetId().get(),
+        lazy(
+            () ->
+                patchSets.stream()
+                    .filter(p -> p.number() == ctx.getChange().currentPatchSetId().get())
+                    .findAny()
+                    .map(p -> p.commitId().name())
+                    .orElse("n/a")));
     ctx.deleteChange();
     changeDeleted.fire(ctx.getChange(), ctx.getAccount(), ctx.getWhen());
     return true;
@@ -112,10 +128,9 @@
 
   @Override
   public void updateRepo(RepoContext ctx) throws IOException {
-    String prefix = PatchSet.id(id, 1).toRefName();
-    prefix = prefix.substring(0, prefix.length() - 1);
-    for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(prefix).entrySet()) {
-      removeRef(ctx, e, prefix);
+    String changeRefPrefix = RefNames.changeRefPrefix(id);
+    for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(changeRefPrefix).entrySet()) {
+      removeRef(ctx, e, changeRefPrefix);
     }
     removeUserEdits(ctx);
   }
diff --git a/java/com/google/gerrit/server/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerAdder.java
index 1c4f63c..9e76888 100644
--- a/java/com/google/gerrit/server/change/ReviewerAdder.java
+++ b/java/com/google/gerrit/server/change/ReviewerAdder.java
@@ -237,7 +237,8 @@
         revision.getUser(),
         ImmutableSet.of(user.getAccountId()),
         null,
-        true);
+        true,
+        false);
   }
 
   @Nullable
@@ -260,7 +261,13 @@
 
     if (isValidReviewer(notes.getChange().getDest(), reviewerUser.getAccount())) {
       return new ReviewerAddition(
-          input, notes, user, ImmutableSet.of(reviewerUser.getAccountId()), null, exactMatchFound);
+          input,
+          notes,
+          user,
+          ImmutableSet.of(reviewerUser.getAccountId()),
+          null,
+          exactMatchFound,
+          false);
     }
     return fail(
         input,
@@ -344,7 +351,7 @@
       }
     }
 
-    return new ReviewerAddition(input, notes, user, reviewers, null, true);
+    return new ReviewerAddition(input, notes, user, reviewers, null, true, true);
   }
 
   @Nullable
@@ -366,7 +373,7 @@
           FailureType.NOT_FOUND,
           MessageFormat.format(ChangeMessages.get().reviewerInvalid, input.reviewer));
     }
-    return new ReviewerAddition(input, notes, user, null, ImmutableList.of(adr), true);
+    return new ReviewerAddition(input, notes, user, null, ImmutableList.of(adr), true, false);
   }
 
   private boolean isValidReviewer(BranchNameKey branch, Account member)
@@ -421,7 +428,8 @@
         CurrentUser caller,
         @Nullable Iterable<Account.Id> reviewers,
         @Nullable Iterable<Address> reviewersByEmail,
-        boolean exactMatchFound) {
+        boolean exactMatchFound,
+        boolean forGroup) {
       checkArgument(
           reviewers != null || reviewersByEmail != null,
           "must have either reviewers or reviewersByEmail");
@@ -435,7 +443,7 @@
       this.reviewersByEmail =
           reviewersByEmail == null ? ImmutableSet.of() : ImmutableSet.copyOf(reviewersByEmail);
       this.caller = caller.asIdentifiedUser();
-      op = addReviewersOpFactory.create(this.reviewers, this.reviewersByEmail, state());
+      op = addReviewersOpFactory.create(this.reviewers, this.reviewersByEmail, state(), forGroup);
       this.exactMatchFound = exactMatchFound;
     }
 
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index fcd38c3..661e376 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -371,7 +371,7 @@
     if (optionalChangeEdit.isPresent()) {
       ChangeEdit changeEdit = optionalChangeEdit.get();
       newTreeId = merge(repository, changeEdit, newTreeId);
-      if (ObjectId.equals(newTreeId, changeEdit.getEditCommit().getTree())) {
+      if (ObjectId.isEqual(newTreeId, changeEdit.getEditCommit().getTree())) {
         // Modifications are already contained in the change edit.
         return changeEdit;
       }
@@ -474,7 +474,7 @@
     treeCreator.addTreeModifications(treeModifications);
     ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
 
-    if (ObjectId.equals(newTreeId, baseCommit.getTree())) {
+    if (ObjectId.isEqual(newTreeId, baseCommit.getTree())) {
       throw new InvalidChangeOperationException("no changes were made");
     }
     return newTreeId;
diff --git a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index d91e2e8..0adacd8 100644
--- a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.restapi.RawInput;
 import java.io.IOException;
 import java.io.InputStream;
+import java.time.Instant;
 import java.util.Collections;
 import java.util.List;
 import org.eclipse.jgit.dircache.DirCacheEditor;
@@ -79,7 +80,7 @@
       try {
         if (dirCacheEntry.getFileMode() == FileMode.GITLINK) {
           dirCacheEntry.setLength(0);
-          dirCacheEntry.setLastModified(0);
+          dirCacheEntry.setLastModified(Instant.EPOCH);
           ObjectId newObjectId = ObjectId.fromString(getNewContentBytes(), 0);
           dirCacheEntry.setObjectId(newObjectId);
         } else {
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index a4f4d93..3f7d864 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "receive",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 38e60d4..13ec5a8 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -377,6 +377,7 @@
 
   private MessageSender messageSender;
   private ResultChangeIds resultChangeIds;
+  private Map<String, String> loggingTags;
 
   @Inject
   ReceiveCommits(
@@ -495,6 +496,7 @@
     // Handles for outputting back over the wire to the end user.
     this.messageSender = messageSender != null ? messageSender : new ReceivePackMessageSender();
     this.resultChangeIds = resultChangeIds;
+    this.loggingTags = new HashMap<>();
   }
 
   void init() {
@@ -522,11 +524,20 @@
   }
 
   void sendMessages() {
-    for (ValidationMessage m : messages) {
-      String msg = m.getType().getPrefix() + m.getMessage();
+    try (TraceContext traceContext =
+        TraceContext.newTrace(
+            loggingTags.containsKey(RequestId.Type.TRACE_ID.name()),
+            loggingTags.get(RequestId.Type.TRACE_ID.name()),
+            (tagName, traceId) -> {})) {
+      loggingTags.forEach((tagName, tagValue) -> traceContext.addTag(tagName, tagValue));
 
-      // Avoid calling sendError which will add its own error: prefix.
-      messageSender.sendMessage(msg);
+      for (ValidationMessage m : messages) {
+        String msg = m.getType().getPrefix() + m.getMessage();
+        logger.atFine().log("Sending message: %s", msg);
+
+        // Avoid calling sendError which will add its own error: prefix.
+        messageSender.sendMessage(msg);
+      }
     }
   }
 
@@ -566,6 +577,8 @@
 
       commandProgress.end();
       progress.end();
+      loggingTags.putAll(traceContext.getTags());
+      logger.atFine().log("Processing commands done.");
     }
   }
 
@@ -3396,6 +3409,7 @@
   }
 
   private static void reject(ReceiveCommand cmd, String why) {
+    logger.atFine().log("Rejecting command '%s': %s", cmd, why);
     cmd.setResult(REJECTED_OTHER_REASON, why);
   }
 
diff --git a/java/com/google/gerrit/server/git/receive/testing/BUILD b/java/com/google/gerrit/server/git/receive/testing/BUILD
index 82cd14b..a5e7998 100644
--- a/java/com/google/gerrit/server/git/receive/testing/BUILD
+++ b/java/com/google/gerrit/server/git/receive/testing/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "testing",
     testonly = 1,
diff --git a/java/com/google/gerrit/server/group/db/testing/BUILD b/java/com/google/gerrit/server/group/db/testing/BUILD
index c13abba..b5d5a43 100644
--- a/java/com/google/gerrit/server/group/db/testing/BUILD
+++ b/java/com/google/gerrit/server/group/db/testing/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 package(default_visibility = ["//visibility:public"])
 
 java_library(
diff --git a/java/com/google/gerrit/server/group/testing/BUILD b/java/com/google/gerrit/server/group/testing/BUILD
index 3ef712c..9b6d8de 100644
--- a/java/com/google/gerrit/server/group/testing/BUILD
+++ b/java/com/google/gerrit/server/group/testing/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 package(default_visibility = ["//visibility:public"])
 
 java_library(
diff --git a/java/com/google/gerrit/server/ioutil/BUILD b/java/com/google/gerrit/server/ioutil/BUILD
index ea91929..ed58d5b 100644
--- a/java/com/google/gerrit/server/ioutil/BUILD
+++ b/java/com/google/gerrit/server/ioutil/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "ioutil",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/server/logging/BUILD b/java/com/google/gerrit/server/logging/BUILD
index f78ff5f..c214d69 100644
--- a/java/com/google/gerrit/server/logging/BUILD
+++ b/java/com/google/gerrit/server/logging/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "logging",
     srcs = glob(
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
index b597a51..15a3532 100644
--- a/java/com/google/gerrit/server/logging/TraceContext.java
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -19,6 +19,7 @@
 import com.google.common.base.Stopwatch;
 import com.google.common.base.Strings;
 import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -231,6 +232,12 @@
     return this;
   }
 
+  public ImmutableMap<String, String> getTags() {
+    ImmutableMap.Builder<String, String> tagMap = ImmutableMap.builder();
+    tags.cellSet().forEach(c -> tagMap.put(c.getRowKey(), c.getColumnKey()));
+    return tagMap.build();
+  }
+
   public TraceContext addPluginTag(String pluginName) {
     return addTag(PLUGIN_TAG, pluginName);
   }
diff --git a/java/com/google/gerrit/server/patch/PatchListLoader.java b/java/com/google/gerrit/server/patch/PatchListLoader.java
index 08de537..b639f96 100644
--- a/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -314,12 +314,12 @@
   }
 
   private static boolean areParentChild(RevCommit commitA, RevCommit commitB) {
-    return ObjectId.equals(commitA.getParent(0), commitB)
-        || ObjectId.equals(commitB.getParent(0), commitA);
+    return ObjectId.isEqual(commitA.getParent(0), commitB)
+        || ObjectId.isEqual(commitB.getParent(0), commitA);
   }
 
   private static boolean haveCommonParent(RevCommit commitA, RevCommit commitB) {
-    return ObjectId.equals(commitA.getParent(0), commitB.getParent(0));
+    return ObjectId.isEqual(commitA.getParent(0), commitB.getParent(0));
   }
 
   private static Set<String> getTouchedFilePaths(PatchListEntry patchListEntry) {
diff --git a/java/com/google/gerrit/server/project/testing/BUILD b/java/com/google/gerrit/server/project/testing/BUILD
index f221e00..968e3da 100644
--- a/java/com/google/gerrit/server/project/testing/BUILD
+++ b/java/com/google/gerrit/server/project/testing/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "project-test-util",
     testonly = True,
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index f2d6e0f..9a2d11b 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 package(
     default_visibility = ["//visibility:public"],
 )
diff --git a/java/com/google/gerrit/server/schema/BUILD b/java/com/google/gerrit/server/schema/BUILD
index aa552ed..fe07791 100644
--- a/java/com/google/gerrit/server/schema/BUILD
+++ b/java/com/google/gerrit/server/schema/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "schema",
     srcs = glob(
diff --git a/java/com/google/gerrit/server/schema/testing/BUILD b/java/com/google/gerrit/server/schema/testing/BUILD
index c520f43..d641c47 100644
--- a/java/com/google/gerrit/server/schema/testing/BUILD
+++ b/java/com/google/gerrit/server/schema/testing/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 package(default_visibility = ["//visibility:public"])
 
 java_library(
diff --git a/java/com/google/gerrit/server/securestore/testing/BUILD b/java/com/google/gerrit/server/securestore/testing/BUILD
index 9b76b9e..c2582b9 100644
--- a/java/com/google/gerrit/server/securestore/testing/BUILD
+++ b/java/com/google/gerrit/server/securestore/testing/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 package(default_testonly = True)
 
 java_library(
diff --git a/java/com/google/gerrit/server/util/git/BUILD b/java/com/google/gerrit/server/util/git/BUILD
index 81ca9cd..a8ae918 100644
--- a/java/com/google/gerrit/server/util/git/BUILD
+++ b/java/com/google/gerrit/server/util/git/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "git",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/server/util/time/BUILD b/java/com/google/gerrit/server/util/time/BUILD
index 710a6b1..d00b42d 100644
--- a/java/com/google/gerrit/server/util/time/BUILD
+++ b/java/com/google/gerrit/server/util/time/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "time",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index 3a69554..33bdf69 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "sshd",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/sshd/commands/FlushCaches.java b/java/com/google/gerrit/sshd/commands/FlushCaches.java
index df56cf4..98562b0 100644
--- a/java/com/google/gerrit/sshd/commands/FlushCaches.java
+++ b/java/com/google/gerrit/sshd/commands/FlushCaches.java
@@ -89,7 +89,8 @@
   @SuppressWarnings("unchecked")
   private void doList() {
     for (String name :
-        (List<String>) listCaches.setFormat(OutputFormat.LIST).apply(new ConfigResource())) {
+        (List<String>)
+            listCaches.setFormat(OutputFormat.LIST).apply(new ConfigResource()).value()) {
       stderr.print(name);
       stderr.print('\n');
     }
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index c19e790..3c617b0 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -194,7 +194,8 @@
 
   private Collection<CacheInfo> getCaches() {
     @SuppressWarnings("unchecked")
-    Map<String, CacheInfo> caches = (Map<String, CacheInfo>) listCaches.apply(new ConfigResource());
+    Map<String, CacheInfo> caches =
+        (Map<String, CacheInfo>) listCaches.apply(new ConfigResource()).value();
     for (Map.Entry<String, CacheInfo> entry : caches.entrySet()) {
       CacheInfo cache = entry.getValue();
       cache.name = entry.getKey();
diff --git a/java/com/google/gerrit/sshd/commands/UploadArchive.java b/java/com/google/gerrit/sshd/commands/UploadArchive.java
index a58e472..c25a1a8 100644
--- a/java/com/google/gerrit/sshd/commands/UploadArchive.java
+++ b/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -139,7 +139,7 @@
     PacketLineIn packetIn = new PacketLineIn(in);
     for (; ; ) {
       String s = packetIn.readString();
-      if (isPacketLineEnd(s)) {
+      if (PacketLineIn.isEnd(s)) {
         break;
       }
       if (!s.startsWith(argCmd)) {
@@ -163,12 +163,6 @@
     }
   }
 
-  // JGit API depends on reference equality with sentinel.
-  @SuppressWarnings({"ReferenceEquality", "StringEquality"})
-  private static boolean isPacketLineEnd(String s) {
-    return s == PacketLineIn.END;
-  }
-
   @Override
   protected void runImpl() throws IOException, PermissionBackendException, Failure {
     PacketLineOut packetOut = new PacketLineOut(out);
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index 27065aa..f5298ea 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "gerrit-test-util",
     testonly = True,
diff --git a/java/com/google/gerrit/truth/BUILD b/java/com/google/gerrit/truth/BUILD
index 4727da1..7c0e743 100644
--- a/java/com/google/gerrit/truth/BUILD
+++ b/java/com/google/gerrit/truth/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "truth",
     testonly = True,
diff --git a/java/com/google/gerrit/util/cli/BUILD b/java/com/google/gerrit/util/cli/BUILD
index b9b9bba..e4f2c21 100644
--- a/java/com/google/gerrit/util/cli/BUILD
+++ b/java/com/google/gerrit/util/cli/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "cli",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/util/http/BUILD b/java/com/google/gerrit/util/http/BUILD
index 30d3adc..5ecb7a1 100644
--- a/java/com/google/gerrit/util/http/BUILD
+++ b/java/com/google/gerrit/util/http/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "http",
     srcs = glob(["**/*.java"]),
diff --git a/java/com/google/gerrit/util/ssl/BUILD b/java/com/google/gerrit/util/ssl/BUILD
index 4f65b61..e0641c7 100644
--- a/java/com/google/gerrit/util/ssl/BUILD
+++ b/java/com/google/gerrit/util/ssl/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "ssl",
     srcs = glob(["**/*.java"]),
diff --git a/java/gerrit/BUILD b/java/gerrit/BUILD
index f416f11..d7e2306 100644
--- a/java/gerrit/BUILD
+++ b/java/gerrit/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "prolog-predicates",
     srcs = glob(["**/*.java"]),
diff --git a/java/org/apache/commons/net/BUILD b/java/org/apache/commons/net/BUILD
index 4951933..c322ecd 100644
--- a/java/org/apache/commons/net/BUILD
+++ b/java/org/apache/commons/net/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "net",
     srcs = glob(["**/*.java"]),
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index c1e83d4..48c9995 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -225,7 +225,7 @@
 
   @Inject
   @Named("accounts")
-  private LoadingCache<Account.Id, Optional<AccountState>> accountsCache;
+  private LoadingCache<Account.Id, AccountState> accountsCache;
 
   @Inject private AccountOperations accountOperations;
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index dda9b88..9fefbe9 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -454,7 +454,7 @@
   }
 
   @Test
-  public void pendingReviewersInNoteDb() throws Exception {
+  public void pendingReviewers() throws Exception {
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
@@ -1918,10 +1918,7 @@
     assertMailReplyTo(m, admin.email());
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
-    // When NoteDb is enabled adding a reviewer records that user as reviewer
-    // in NoteDb. When NoteDb is disabled adding a reviewer results in a dummy 0
-    // approval on the change which is treated as CC when the ChangeInfo is
-    // created.
+    // Adding a reviewer records that user as reviewer.
     Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
@@ -2033,10 +2030,7 @@
     assertMailReplyTo(m, email);
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
-    // When NoteDb is enabled adding a reviewer records that user as reviewer
-    // in NoteDb. When NoteDb is disabled adding a reviewer results in a dummy 0
-    // approval on the change which is treated as CC when the ChangeInfo is
-    // created.
+    // Adding a reviewer records that user as reviewer.
     Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
@@ -2097,10 +2091,7 @@
     assertMailReplyTo(m, myGroupUserEmail);
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
-    // When NoteDb is enabled adding a reviewer records that user as reviewer
-    // in NoteDb. When NoteDb is disabled adding a reviewer results in a dummy 0
-    // approval on the change which is treated as CC when the ChangeInfo is
-    // created.
+    // Adding a reviewer records that user as reviewer.
     Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
@@ -2128,10 +2119,7 @@
     // There should be no email notification when adding self
     assertThat(sender.getMessages()).isEmpty();
 
-    // When NoteDb is enabled adding a reviewer records that user as reviewer
-    // in NoteDb. When NoteDb is disabled adding a reviewer results in a dummy 0
-    // approval on the change which is treated as CC when the ChangeInfo is
-    // created.
+    // Adding a reviewer records that user as reviewer.
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
@@ -2187,7 +2175,7 @@
         .containsExactly(user.id().get());
 
     // Further test: remove the vote, then comment again. The user should be
-    // implicitly re-added to the ReviewerSet, as a CC if we're using NoteDb.
+    // implicitly re-added to the ReviewerSet, as a CC.
     requestScopeOperations.setApiUser(admin.id());
     gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).remove();
     c = gApi.changes().id(r.getChangeId()).get();
@@ -3177,7 +3165,7 @@
   }
 
   @Test
-  public void noteDbCommitsOnPatchSetCreation() throws Exception {
+  public void commitsOnPatchSetCreation() throws Exception {
     PushOneCommit.Result r = createChange();
     pushFactory
         .create(admin.newIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "4711", r.getChangeId())
@@ -4366,10 +4354,7 @@
     public boolean updateChange(ChangeContext ctx) throws Exception {
       Change change = ctx.getChange();
 
-      // Change status in database.
-      change.setStatus(newStatus);
-
-      // Change status in NoteDb.
+      // Change status.
       PatchSet.Id currentPatchSetId = change.currentPatchSetId();
       ctx.getUpdate(currentPatchSetId).setStatus(newStatus);
 
diff --git a/javatests/com/google/gerrit/acceptance/api/group/BUILD b/javatests/com/google/gerrit/acceptance/api/group/BUILD
index a12342a..e311e25 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/group/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 89dbabf..27cc241 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -2201,6 +2201,17 @@
   }
 
   @Test
+  public void noEditAndUpdateAllUsersInSameChangeStack() throws Exception {
+    List<RevCommit> commits = createChanges(2, "refs/for/master");
+    String id2 = byCommit(commits.get(1)).change().getKey().get();
+    addDraft(id2, commits.get(1).name(), newDraft(FILE_NAME, 1, "comment2"));
+    // First change in stack unchanged.
+    RevCommit unChanged = commits.remove(0);
+    // Publishing draft comments on change 2 updates All-Users.
+    amendChanges(unChanged.toObjectId(), commits, "refs/for/master%publish-comments");
+  }
+
+  @Test
   public void pushWithDraftOptionIsDisabledPerDefault() throws Exception {
     for (String ref : ImmutableSet.of("refs/drafts/master", "refs/for/master%draft")) {
       PushOneCommit.Result r = pushTo(ref);
diff --git a/javatests/com/google/gerrit/acceptance/git/BUILD b/javatests/com/google/gerrit/acceptance/git/BUILD
index 0de307a..dfaf9e3 100644
--- a/javatests/com/google/gerrit/acceptance/git/BUILD
+++ b/javatests/com/google/gerrit/acceptance/git/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 [acceptance_tests(
diff --git a/javatests/com/google/gerrit/acceptance/pgm/BUILD b/javatests/com/google/gerrit/acceptance/pgm/BUILD
index e0ed78a..d15c6ce 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/BUILD
+++ b/javatests/com/google/gerrit/acceptance/pgm/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
index 63e9ebf..c4418c0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
@@ -25,9 +25,9 @@
 public class AccountAssert {
 
   public static void assertAccountInfo(TestAccount a, AccountInfo ai) {
-    assertThat(a.id().get()).isEqualTo(ai._accountId);
-    assertThat(a.fullName()).isEqualTo(ai.name);
-    assertThat(a.email()).isEqualTo(ai.email);
+    assertThat(ai._accountId).isEqualTo(a.id().get());
+    assertThat(ai.name).isEqualTo(a.fullName());
+    assertThat(ai.email).isEqualTo(a.email());
   }
 
   public static void assertAccountInfos(List<TestAccount> expected, List<AccountInfo> actual) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/BUILD b/javatests/com/google/gerrit/acceptance/rest/account/BUILD
index 17a6053..e801dcc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/account/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/BUILD b/javatests/com/google/gerrit/acceptance/rest/change/BUILD
index 9a65378..7ccf10f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/change/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 SUBMIT_UTIL_SRCS = glob(["AbstractSubmit*.java"])
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index e300c91..96b96eb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
@@ -390,8 +391,7 @@
     assertThat(result.reviewers).isNotNull();
     assertThat(result.reviewers).hasSize(2);
 
-    // Verify reviewer and CC were added. If not in NoteDb read mode, both
-    // parties will be returned as CCed.
+    // Verify reviewer and CC were added.
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     assertReviewers(c, REVIEWER, admin, user);
     assertReviewers(c, CC, observer);
@@ -492,7 +492,7 @@
   }
 
   @Test
-  public void noteDbAddReviewerToReviewerChangeInfo() throws Exception {
+  public void addReviewerToReviewerChangeInfo() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     AddReviewerInput in = new AddReviewerInput();
@@ -506,7 +506,7 @@
     gApi.changes().id(changeId).current().review(ReviewInput.dislike());
 
     requestScopeOperations.setApiUser(user.id());
-    // NoteDb adds reviewer to a change on every review.
+    // By posting a review the user is added as reviewer.
     gApi.changes().id(changeId).current().review(ReviewInput.dislike());
 
     deleteReviewer(changeId, user).assertNoContent();
@@ -756,6 +756,81 @@
     assertThat(gApi.changes().id(r.getChangeId()).addReviewer(input).ccs).isEmpty();
   }
 
+  @Test
+  public void moveCcToReviewer() throws Exception {
+    // Create a change and add 'user' as CC.
+    String changeId = createChange().getChangeId();
+    AddReviewerInput reviewerInput = new AddReviewerInput();
+    reviewerInput.reviewer = user.email();
+    reviewerInput.state = ReviewerState.CC;
+    gApi.changes().id(changeId).addReviewer(reviewerInput);
+
+    // Verify that 'user' is a CC on the change and that there are no reviewers.
+    ChangeInfo c = gApi.changes().id(changeId).get();
+    Collection<AccountInfo> ccs = c.reviewers.get(CC);
+    assertThat(ccs).isNotNull();
+    assertThat(ccs).hasSize(1);
+    assertThat(ccs.iterator().next()._accountId).isEqualTo(user.id().get());
+    assertThat(c.reviewers.get(REVIEWER)).isNull();
+
+    // Move 'user' from CC to reviewer.
+    gApi.changes().id(changeId).addReviewer(user.id().toString());
+
+    // Verify that 'user' is a reviewer on the change now and that there are no CCs.
+    c = gApi.changes().id(changeId).get();
+    Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
+    assertThat(reviewers).isNotNull();
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.id().get());
+    assertThat(c.reviewers.get(CC)).isNull();
+  }
+
+  @Test
+  public void moveReviewerToCc() throws Exception {
+    // Allow everyone to approve changes.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .update();
+
+    // Create a change and add 'user' as reviewer.
+    String changeId = createChange().getChangeId();
+    gApi.changes().id(changeId).addReviewer(user.id().toString());
+
+    // Verify that 'user' is a reviewer on the change and that there are no CCs.
+    ChangeInfo c = gApi.changes().id(changeId).get();
+    Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
+    assertThat(reviewers).isNotNull();
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.id().get());
+    assertThat(c.reviewers.get(CC)).isNull();
+
+    // Let 'user' approve the change and verify that the change has the approval.
+    requestScopeOperations.setApiUser(user.id());
+    approve(changeId);
+    c = gApi.changes().id(changeId).get();
+    assertThat(c.labels.get("Code-Review").approved._accountId).isEqualTo(user.id().get());
+
+    // Move 'user' from reviewer to CC.
+    requestScopeOperations.setApiUser(admin.id());
+    AddReviewerInput reviewerInput = new AddReviewerInput();
+    reviewerInput.reviewer = user.id().toString();
+    reviewerInput.state = CC;
+    gApi.changes().id(changeId).addReviewer(reviewerInput);
+
+    // Verify that 'user' is a CC on the change now and that there are no reviewers.
+    c = gApi.changes().id(changeId).get();
+    Collection<AccountInfo> ccs = c.reviewers.get(CC);
+    assertThat(ccs).isNotNull();
+    assertThat(ccs).hasSize(1);
+    assertThat(ccs.iterator().next()._accountId).isEqualTo(user.id().get());
+    assertThat(c.reviewers.get(REVIEWER)).isNull();
+
+    // Verify that the approval of 'user' is still there.
+    assertThat(c.labels.get("Code-Review").approved._accountId).isEqualTo(user.id().get());
+  }
+
   private void assertThatUserIsOnlyReviewer(String changeId) throws Exception {
     AccountInfo userInfo = new AccountInfo(user.fullName(), user.getEmailAddress().getEmail());
     userInfo._accountId = user.id().get();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BUILD b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
index 131c24a..200b26a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
diff --git a/javatests/com/google/gerrit/acceptance/rest/util/BUILD b/javatests/com/google/gerrit/acceptance/rest/util/BUILD
index cc72e8a..1d3fe65 100644
--- a/javatests/com/google/gerrit/acceptance/rest/util/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/util/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "util",
     testonly = True,
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/BUILD b/javatests/com/google/gerrit/acceptance/server/mail/BUILD
index e21789b..5d7e65e 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/mail/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 DEPS = [
diff --git a/javatests/com/google/gerrit/acceptance/ssh/BUILD b/javatests/com/google/gerrit/acceptance/ssh/BUILD
index 00a0914..5634322 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/BUILD
+++ b/javatests/com/google/gerrit/acceptance/ssh/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 java_library(
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
index a2bd092..e50f2b5 100644
--- a/javatests/com/google/gerrit/elasticsearch/BUILD
+++ b/javatests/com/google/gerrit/elasticsearch/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//tools/bzl:junit.bzl", "junit_tests")
 
 java_library(
diff --git a/javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java b/javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java
index 7f22275..17c3fdc 100644
--- a/javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java
+++ b/javatests/com/google/gerrit/reviewdb/client/RefNamesTest.java
@@ -50,6 +50,9 @@
     String robotCommentsRef = RefNames.robotCommentsRef(changeId);
     assertThat(robotCommentsRef).isEqualTo("refs/changes/73/67473/robot-comments");
     assertThat(RefNames.isNoteDbMetaRef(robotCommentsRef)).isTrue();
+
+    String changeRefPrefix = RefNames.changeRefPrefix(changeId);
+    assertThat(changeRefPrefix).isEqualTo("refs/changes/73/67473/");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index f6ed5ef..1bb22e4 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//tools/bzl:junit.bzl", "junit_tests")
 
 CUSTOM_TRUTH_SUBJECTS = glob([
diff --git a/javatests/com/google/gerrit/server/query/account/BUILD b/javatests/com/google/gerrit/server/query/account/BUILD
index e41d390..7b72f4e 100644
--- a/javatests/com/google/gerrit/server/query/account/BUILD
+++ b/javatests/com/google/gerrit/server/query/account/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//tools/bzl:junit.bzl", "junit_tests")
 
 ABSTRACT_QUERY_TEST = ["AbstractQueryAccountsTest.java"]
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index a128593..67b7c47 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//tools/bzl:junit.bzl", "junit_tests")
 
 ABSTRACT_QUERY_TEST = ["AbstractQueryChangesTest.java"]
diff --git a/javatests/com/google/gerrit/server/query/group/BUILD b/javatests/com/google/gerrit/server/query/group/BUILD
index 3f147c9..1271f4e 100644
--- a/javatests/com/google/gerrit/server/query/group/BUILD
+++ b/javatests/com/google/gerrit/server/query/group/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//tools/bzl:junit.bzl", "junit_tests")
 
 ABSTRACT_QUERY_TEST = ["AbstractQueryGroupsTest.java"]
diff --git a/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
index 4ce1c00..e978be6 100644
--- a/javatests/com/google/gerrit/server/query/project/BUILD
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//tools/bzl:junit.bzl", "junit_tests")
 
 ABSTRACT_QUERY_TEST = ["AbstractQueryProjectsTest.java"]
diff --git a/javatests/com/google/gerrit/util/http/testutil/BUILD b/javatests/com/google/gerrit/util/http/testutil/BUILD
index adae68e..5cb94c6 100644
--- a/javatests/com/google/gerrit/util/http/testutil/BUILD
+++ b/javatests/com/google/gerrit/util/http/testutil/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "testutil",
     testonly = True,
diff --git a/lib/BUILD b/lib/BUILD
index f98f6fe..ab2bad9 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 exports_files(glob([
     "LICENSE-*",
 ]))
diff --git a/lib/antlr/BUILD b/lib/antlr/BUILD
index c35c2b5..076aea9 100644
--- a/lib/antlr/BUILD
+++ b/lib/antlr/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_binary", "java_library")
+
 package(default_visibility = ["//java/com/google/gerrit/index:__pkg__"])
 
 [java_library(
diff --git a/lib/asciidoctor/BUILD b/lib/asciidoctor/BUILD
index 62b1114..b46c08d 100644
--- a/lib/asciidoctor/BUILD
+++ b/lib/asciidoctor/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "asciidoctor",
     data = ["//lib:LICENSE-asciidoctor"],
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index 1e722bc..b60a101 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library", "java_plugin")
+
 java_plugin(
     name = "auto-annotation-plugin",
     processor_class = "com.google.auto.value.processor.AutoAnnotationProcessor",
diff --git a/lib/bouncycastle/BUILD b/lib/bouncycastle/BUILD
index cf3e996..43ba6e1 100644
--- a/lib/bouncycastle/BUILD
+++ b/lib/bouncycastle/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "bcprov",
     data = ["//lib:LICENSE-bouncycastle"],
diff --git a/lib/commons/BUILD b/lib/commons/BUILD
index e8de396..38b1b6d 100644
--- a/lib/commons/BUILD
+++ b/lib/commons/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 package(default_visibility = ["//visibility:public"])
 
 java_library(
diff --git a/lib/dropwizard/BUILD b/lib/dropwizard/BUILD
index 4ae12f1..174b7ad 100644
--- a/lib/dropwizard/BUILD
+++ b/lib/dropwizard/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "dropwizard-core",
     data = ["//lib:LICENSE-Apache2.0"],
diff --git a/lib/easymock/BUILD b/lib/easymock/BUILD
index 352d2a7..90c9673 100644
--- a/lib/easymock/BUILD
+++ b/lib/easymock/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "easymock",
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
diff --git a/lib/elasticsearch-rest-client/BUILD b/lib/elasticsearch-rest-client/BUILD
index 8df3c70..e323263 100644
--- a/lib/elasticsearch-rest-client/BUILD
+++ b/lib/elasticsearch-rest-client/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 package(default_visibility = ["//visibility:public"])
 
 java_library(
diff --git a/lib/errorprone/BUILD b/lib/errorprone/BUILD
index b5c130b..456860a 100644
--- a/lib/errorprone/BUILD
+++ b/lib/errorprone/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "annotations",
     data = ["//lib:LICENSE-Apache2.0"],
diff --git a/lib/flogger/BUILD b/lib/flogger/BUILD
index c41e12f..35c3c62 100644
--- a/lib/flogger/BUILD
+++ b/lib/flogger/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "api",
     data = ["//lib:LICENSE-Apache2.0"],
diff --git a/lib/gitiles/BUILD b/lib/gitiles/BUILD
index b1bbca1..6e03801 100644
--- a/lib/gitiles/BUILD
+++ b/lib/gitiles/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "gitiles",
     visibility = ["//visibility:public"],
diff --git a/lib/greenmail/BUILD b/lib/greenmail/BUILD
index 9cbd0eb..e8845e2 100644
--- a/lib/greenmail/BUILD
+++ b/lib/greenmail/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 package(default_visibility = ["//visibility:public"])
 
 POST_JDK8_DEPS = [":javax-activation"]
diff --git a/lib/guice/BUILD b/lib/guice/BUILD
index 7f384e2..f73984b 100644
--- a/lib/guice/BUILD
+++ b/lib/guice/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "guice",
     data = ["//lib:LICENSE-Apache2.0"],
diff --git a/lib/httpcomponents/BUILD b/lib/httpcomponents/BUILD
index 03d9b68..07d4bb9 100644
--- a/lib/httpcomponents/BUILD
+++ b/lib/httpcomponents/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 package(default_visibility = ["//visibility:public"])
 
 java_library(
diff --git a/lib/jackson/BUILD b/lib/jackson/BUILD
index 0034748..3eed77a 100644
--- a/lib/jackson/BUILD
+++ b/lib/jackson/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "jackson-core",
     data = ["//lib:LICENSE-Apache2.0"],
diff --git a/lib/jetty/BUILD b/lib/jetty/BUILD
index b78ac58..6417385 100644
--- a/lib/jetty/BUILD
+++ b/lib/jetty/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "servlet",
     data = ["//lib:LICENSE-Apache2.0"],
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
index 0f52913..b3d026d 100644
--- a/lib/jgit/jgit.bzl
+++ b/lib/jgit/jgit.bzl
@@ -1,6 +1,6 @@
 load("//tools/bzl:maven_jar.bzl", "MAVEN_CENTRAL", "maven_jar")
 
-_JGIT_VERS = "5.3.1.201904271842-r"
+_JGIT_VERS = "5.4.3.201909031940-r"
 
 _DOC_VERS = _JGIT_VERS  # Set to _JGIT_VERS unless using a snapshot
 
@@ -40,25 +40,25 @@
         name = "jgit-lib",
         artifact = "org.eclipse.jgit:org.eclipse.jgit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "dba85014483315fa426259bc1b8ccda9373a624b",
+        sha1 = "10322c4e103485f8b4873cbbf982342f9c3d7989",
     )
     maven_jar(
         name = "jgit-servlet",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "3287341fca859340a00b51cb5dd3b78b8e532b39",
+        sha1 = "59d0c943343f30612e4e2a5a3bf1b95b56e00207",
     )
     maven_jar(
         name = "jgit-archive",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "3585027e83fb44a5de2c10ae9ddbf976593bf080",
+        sha1 = "21dc4a10882dc667c83bf82a563a6fc4d7719456",
     )
     maven_jar(
         name = "jgit-junit",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "3d9ba7e610d6ab5d08dcb1e4ba448b592a34de77",
+        sha1 = "71659fc1a1729b7c67846dac8cd6a762fa72002a",
     )
 
 def jgit_dep(name):
diff --git a/lib/jgit/org.eclipse.jgit.archive/BUILD b/lib/jgit/org.eclipse.jgit.archive/BUILD
index 2742623..151cd71 100644
--- a/lib/jgit/org.eclipse.jgit.archive/BUILD
+++ b/lib/jgit/org.eclipse.jgit.archive/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//lib/jgit:jgit.bzl", "jgit_dep")
 
 java_library(
diff --git a/lib/jgit/org.eclipse.jgit.http.server/BUILD b/lib/jgit/org.eclipse.jgit.http.server/BUILD
index 001ad8b..fd634a5 100644
--- a/lib/jgit/org.eclipse.jgit.http.server/BUILD
+++ b/lib/jgit/org.eclipse.jgit.http.server/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//lib/jgit:jgit.bzl", "jgit_dep")
 
 java_library(
diff --git a/lib/jgit/org.eclipse.jgit.junit/BUILD b/lib/jgit/org.eclipse.jgit.junit/BUILD
index 29d80d3..abc522b 100644
--- a/lib/jgit/org.eclipse.jgit.junit/BUILD
+++ b/lib/jgit/org.eclipse.jgit.junit/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//lib/jgit:jgit.bzl", "jgit_dep")
 
 java_library(
diff --git a/lib/jgit/org.eclipse.jgit/BUILD b/lib/jgit/org.eclipse.jgit/BUILD
index dc11171..c1f2607 100644
--- a/lib/jgit/org.eclipse.jgit/BUILD
+++ b/lib/jgit/org.eclipse.jgit/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//lib/jgit:jgit.bzl", "jgit_dep")
 
 java_library(
diff --git a/lib/jsoup/BUILD b/lib/jsoup/BUILD
index 3142dac..7171901 100644
--- a/lib/jsoup/BUILD
+++ b/lib/jsoup/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "jsoup",
     data = ["//lib:LICENSE-jsoup"],
diff --git a/lib/log/BUILD b/lib/log/BUILD
index 8e4c927..128e8ba 100644
--- a/lib/log/BUILD
+++ b/lib/log/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "api",
     data = ["//lib:LICENSE-slf4j"],
diff --git a/lib/lucene/BUILD b/lib/lucene/BUILD
index adb5030..b8b2457 100644
--- a/lib/lucene/BUILD
+++ b/lib/lucene/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//tools/bzl:maven.bzl", "merge_maven_jars")
 
 package(default_visibility = ["//visibility:public"])
diff --git a/lib/mail/BUILD b/lib/mail/BUILD
index eca2b6b..489f544 100644
--- a/lib/mail/BUILD
+++ b/lib/mail/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "mail",
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
diff --git a/lib/mime4j/BUILD b/lib/mime4j/BUILD
index ee407c3..577661d 100644
--- a/lib/mime4j/BUILD
+++ b/lib/mime4j/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "core",
     data = ["//lib:LICENSE-Apache2.0"],
diff --git a/lib/mina/BUILD b/lib/mina/BUILD
index 6ee7e41..5ad47cd 100644
--- a/lib/mina/BUILD
+++ b/lib/mina/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "sshd",
     data = ["//lib:LICENSE-Apache2.0"],
diff --git a/lib/mockito/BUILD b/lib/mockito/BUILD
index fa4839b..7af9669 100644
--- a/lib/mockito/BUILD
+++ b/lib/mockito/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 package(
     default_testonly = True,
     default_visibility = ["//visibility:private"],
@@ -18,13 +20,13 @@
 java_library(
     name = "byte-buddy",
     data = ["//lib:LICENSE-Apache2.0"],
-    exports = ["@byte-buddy//jar"],
+    exports = ["@bytebuddy//jar"],
 )
 
 java_library(
     name = "byte-buddy-agent",
     data = ["//lib:LICENSE-Apache2.0"],
-    exports = ["@byte-buddy-agent//jar"],
+    exports = ["@bytebuddy-agent//jar"],
 )
 
 java_library(
diff --git a/lib/openid/BUILD b/lib/openid/BUILD
index faa073b..c27e8ab 100644
--- a/lib/openid/BUILD
+++ b/lib/openid/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "consumer",
     data = ["//lib:LICENSE-Apache2.0"],
diff --git a/lib/ow2/BUILD b/lib/ow2/BUILD
index 5a82572..7fe7e2d 100644
--- a/lib/ow2/BUILD
+++ b/lib/ow2/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "ow2-asm",
     data = ["//lib:LICENSE-ow2"],
diff --git a/lib/powermock/BUILD b/lib/powermock/BUILD
index 57880f4..39df164 100644
--- a/lib/powermock/BUILD
+++ b/lib/powermock/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "powermock-module-junit4",
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
diff --git a/lib/prolog/BUILD b/lib/prolog/BUILD
index 8518af7..fa55682 100644
--- a/lib/prolog/BUILD
+++ b/lib/prolog/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_binary", "java_library")
+
 java_library(
     name = "runtime",
     data = ["//lib:LICENSE-prologcafe"],
@@ -42,14 +44,14 @@
 
 java_binary(
     name = "compiler-bin",
-    main_class = "BuckPrologCompiler",
+    main_class = "BazelPrologCompiler",
     visibility = ["//visibility:public"],
     runtime_deps = [":compiler-lib"],
 )
 
 java_library(
     name = "compiler-lib",
-    srcs = ["java/BuckPrologCompiler.java"],
+    srcs = ["java/BazelPrologCompiler.java"],
     visibility = ["//visibility:public"],
     deps = [
         ":compiler",
diff --git a/lib/prolog/java/BuckPrologCompiler.java b/lib/prolog/java/BazelPrologCompiler.java
similarity index 98%
rename from lib/prolog/java/BuckPrologCompiler.java
rename to lib/prolog/java/BazelPrologCompiler.java
index cc3e39e..37ea696 100644
--- a/lib/prolog/java/BuckPrologCompiler.java
+++ b/lib/prolog/java/BazelPrologCompiler.java
@@ -21,7 +21,7 @@
 import java.util.jar.JarEntry;
 import java.util.jar.JarOutputStream;
 
-public class BuckPrologCompiler {
+public class BazelPrologCompiler {
   private static File tmpdir;
 
   public static void main(String[] argv) throws IOException, CompileException {
diff --git a/lib/prolog/prolog.bzl b/lib/prolog/prolog.bzl
index 4d4dd3a..ffc3198 100644
--- a/lib/prolog/prolog.bzl
+++ b/lib/prolog/prolog.bzl
@@ -12,6 +12,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+load("@rules_java//java:defs.bzl", "java_library")
+
 def prolog_cafe_library(
         name,
         srcs,
@@ -26,7 +28,7 @@
         tools = ["//lib/prolog:compiler-bin"],
         outs = [name + ".srcjar"],
     )
-    native.java_library(
+    java_library(
         name = name,
         srcs = [":" + name + "__pl2j"],
         deps = ["//lib/prolog:runtime-neverlink"] + deps,
diff --git a/lib/testcontainers/BUILD b/lib/testcontainers/BUILD
index 25ca327..a37b733 100644
--- a/lib/testcontainers/BUILD
+++ b/lib/testcontainers/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "duct-tape",
     testonly = True,
diff --git a/lib/truth/BUILD b/lib/truth/BUILD
index db5bc48..bb30945 100644
--- a/lib/truth/BUILD
+++ b/lib/truth/BUILD
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
     name = "truth",
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
diff --git a/plugins/BUILD b/plugins/BUILD
index 7d81213..3e93768 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_binary", "java_library")
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//tools/bzl:javadoc.bzl", "java_doc")
 load(
diff --git a/plugins/delete-project b/plugins/delete-project
index b618043..3a4b095 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit b618043544ebc62a0730aa1bfc1a1e26011b471a
+Subproject commit 3a4b0955529588dbc071bc43164c468b29d79129
diff --git a/plugins/gitiles b/plugins/gitiles
index 3764262..bdbed9a 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit 37642627f0a4e6a3b2990ec754120b5055de6d41
+Subproject commit bdbed9af9bb2b77cd7fc8681da2dcee7e8f30264
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index 833889d..69343c6 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit 833889d327a159b5ccea7064f4fcff3f94d4b26e
+Subproject commit 69343c65a66d752c3a41788c191a38fc64cc2a32
diff --git a/plugins/replication b/plugins/replication
index a3ca5f8..485027c 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit a3ca5f86bb58852b106e3fdb91a79a6cc11bf312
+Subproject commit 485027c2d63dda7b588fa5c07f91d2a65db4d787
diff --git a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html
index db11937..182d242 100644
--- a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html
@@ -18,8 +18,6 @@
 (function(window) {
   'use strict';
 
-  const ACCOUNT_CAPABILITIES = ['createProject', 'createGroup', 'viewPlugins'];
-
   const ADMIN_LINKS = [{
     name: 'Repositories',
     noBaseUrl: true,
@@ -65,7 +63,7 @@
         return Promise.resolve(this._filterLinks(link => link.viewableToAll,
             getAdminMenuLinks, opt_options));
       }
-      return getAccountCapabilities(ACCOUNT_CAPABILITIES)
+      return getAccountCapabilities()
           .then(capabilities => {
             return this._filterLinks(link => {
               return !link.capability ||
@@ -97,9 +95,10 @@
       links.push(...getAdminMenuLinks().map(link => ({
         url: link.url,
         name: link.text,
+        capability: link.capability || null,
         noBaseUrl: !isExernalLink(link),
         view: null,
-        viewableToAll: true,
+        viewableToAll: !link.capability,
         target: isExernalLink(link) ? '_blank' : null,
       })));
 
diff --git a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html
index 60817aa..f1e28d1 100644
--- a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html
@@ -310,5 +310,61 @@
         testAdminLinks(account, options, expected, done);
       });
     });
+
+
+    suite('view plugin screen with plugin capability', () => {
+      const account = {
+        name: 'test-user',
+      };
+      let expected;
+
+      setup(() => {
+        capabilityStub.returns(Promise.resolve({pluginCapability: true}));
+        expected = {};
+      });
+
+      test('with plugin with capabilities', done => {
+        let options;
+        const generatedLinks = [
+          {text: 'without capability', url: '/without'},
+          {text: 'with capability', url: '/with', capability: 'pluginCapability'},
+        ];
+        menuLinkStub.returns(generatedLinks);
+        expected = Object.assign(expected, {
+          totalLength: 4,
+          pluginGeneratedLinks: generatedLinks,
+        });
+        testAdminLinks(account, options, expected, done);
+      });
+    });
+
+
+    suite('view plugin screen without plugin capability', () => {
+      const account = {
+        name: 'test-user',
+      };
+      let expected;
+
+      setup(() => {
+        capabilityStub.returns(Promise.resolve({}));
+        expected = {};
+      });
+
+      test('with plugin with capabilities', done => {
+        let options;
+        const generatedLinks = [
+          {text: 'without capability', url: '/without'},
+          {text: 'with capability',
+            url: '/with',
+            capability: 'pluginCapability'},
+        ];
+        menuLinkStub.returns(generatedLinks);
+        expected = Object.assign(expected, {
+          totalLength: 3,
+          pluginGeneratedLinks: [generatedLinks[0]],
+        });
+        testAdminLinks(account, options, expected, done);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.html
similarity index 60%
rename from polygerrit-ui/app/behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html
rename to polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.html
index 40379e4..3106fc8 100644
--- a/polygerrit-ui/app/behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.html
@@ -15,33 +15,28 @@
 limitations under the License.
 -->
 
+<script src="../../scripts/gr-display-name-utils/gr-display-name-utils.js"></script>
+
 <script>
 (function(window) {
   'use strict';
 
-  const ANONYMOUS_NAME = 'Anonymous';
-
   window.Gerrit = window.Gerrit || {};
 
-  /** @polymerBehavior Gerrit.AnonymousNameBehavior */
-  Gerrit.AnonymousNameBehavior = {
+  /** @polymerBehavior Gerrit.DisplayNameBehavior */
+  Gerrit.DisplayNameBehavior = {
+    // TODO(dmfilippov) replace DisplayNameBehavior with GrDisplayNameUtils
+
     /**
      * enableEmail when true enables to fallback to using email if
      * the account name is not avilable.
      */
     getUserName(config, account, enableEmail) {
-      if (account && account.name) {
-        return account.name;
-      } else if (account && account.username) {
-        return account.username;
-      } else if (enableEmail && account && account.email) {
-        return account.email;
-      } else if (config && config.user &&
-          config.user.anonymous_coward_name !== 'Anonymous Coward') {
-        return config.user.anonymous_coward_name;
-      }
+      return GrDisplayNameUtils.getUserName(config, account, enableEmail);
+    },
 
-      return ANONYMOUS_NAME;
+    getGroupDisplayName(group) {
+      return GrDisplayNameUtils.getGroupDisplayName(group);
     },
   };
 })(window);
diff --git a/polygerrit-ui/app/behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior_test.html b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html
similarity index 79%
rename from polygerrit-ui/app/behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior_test.html
rename to polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html
index 64f0b3a..4c5c899 100644
--- a/polygerrit-ui/app/behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html
@@ -17,14 +17,14 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-anonymous-name-behavior</title>
+<title>gr-display-name-behavior</title>
 <script src="/test/common-test-setup.js"></script>
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
 <script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../test/common-test-setup.html"/>
-<link rel="import" href="gr-anonymous-name-behavior.html">
+<link rel="import" href="gr-display-name-behavior.html">
 
 <test-fixture id="basic">
   <template>
@@ -33,7 +33,7 @@
 </test-fixture>
 
 <script>
-  suite('gr-anonymous-name-behavior tests', () => {
+  suite('gr-display-name-behavior tests', () => {
     let element;
     // eslint-disable-next-line no-unused-vars
     const config = {
@@ -48,7 +48,7 @@
         is: 'test-element-anon',
         _legacyUndefinedCheck: true,
         behaviors: [
-          Gerrit.AnonymousNameBehavior,
+          Gerrit.DisplayNameBehavior,
         ],
       });
     });
@@ -57,21 +57,21 @@
       element = fixture('basic');
     });
 
-    test('test for it to return name', () => {
+    test('getUserName name only', () => {
       const account = {
         name: 'test-name',
       };
       assert.deepEqual(element.getUserName(config, account, true), 'test-name');
     });
 
-    test('test for it to return username', () => {
+    test('getUserName username only', () => {
       const account = {
         username: 'test-user',
       };
       assert.deepEqual(element.getUserName(config, account, true), 'test-user');
     });
 
-    test('test for it to return email', () => {
+    test('getUserName email only', () => {
       const account = {
         email: 'test-user@test-url.com',
       };
@@ -79,11 +79,11 @@
           'test-user@test-url.com');
     });
 
-    test('test for it not to Anonymous Coward as the anon name', () => {
+    test('getUserName returns not Anonymous Coward as the anon name', () => {
       assert.deepEqual(element.getUserName(config, null, true), 'Anonymous');
     });
 
-    test('test for the config returning the anon name', () => {
+    test('getUserName for the config returning the anon name', () => {
       const config = {
         user: {
           anonymous_coward_name: 'Test Anon',
@@ -91,5 +91,10 @@
       };
       assert.deepEqual(element.getUserName(config, null, true), 'Test Anon');
     });
+
+    test('getGroupDisplayName', () => {
+      assert.equal(element.getGroupDisplayName({name: 'Some user name'}),
+          'Some user name (group)');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
index 178d056..984be19 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
@@ -160,6 +160,7 @@
       return element.reload().then(() => {
         assert.equal(element._filteredLinks.length, 3);
         assert.deepEqual(element._filteredLinks[1], {
+          capability: null,
           url: '/internal/link/url',
           name: 'internal link text',
           noBaseUrl: true,
@@ -168,6 +169,7 @@
           target: null,
         });
         assert.deepEqual(element._filteredLinks[2], {
+          capability: null,
           url: 'http://external/link/url',
           name: 'external link text',
           noBaseUrl: false,
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
deleted file mode 100644
index 1cb1ca5..0000000
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
+++ /dev/null
@@ -1,186 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-(function() {
-  'use strict';
-
-  Polymer({
-    is: 'gr-account-entry',
-    _legacyUndefinedCheck: true,
-
-    /**
-     * Fired when an account is entered.
-     *
-     * @event add
-     */
-
-    /**
-     * When allowAnyInput is true, account-text-changed is fired when input text
-     * changed. This is needed so that the reply dialog's save button can be
-     * enabled for arbitrary cc's, which don't need a 'commit'.
-     *
-     * @event account-text-changed
-     */
-    properties: {
-      allowAnyInput: Boolean,
-      borderless: Boolean,
-      change: Object,
-      filter: Function,
-      placeholder: String,
-      /**
-       * When true, account-entry uses the account suggest API endpoint, which
-       * suggests any account in that Gerrit instance (and does not suggest
-       * groups).
-       *
-       * When false/undefined, account-entry uses the suggest_reviewers API
-       * endpoint, which suggests any account or group in that Gerrit instance
-       * that is not already a reviewer (or is not CCed) on that change.
-       */
-      allowAnyUser: Boolean,
-
-      // suggestFrom = 0 to enable default suggestions.
-      suggestFrom: {
-        type: Number,
-        value: 0,
-      },
-
-      query: {
-        type: Function,
-        value() {
-          return this._getReviewerSuggestions.bind(this);
-        },
-      },
-
-      _config: Object,
-      /** The value of the autocomplete entry. */
-      _inputText: {
-        type: String,
-        observer: '_inputTextChanged',
-      },
-
-      _loggedIn: Boolean,
-    },
-
-    behaviors: [
-      Gerrit.AnonymousNameBehavior,
-      Gerrit.FireBehavior,
-    ],
-
-    attached() {
-      this.$.restAPI.getConfig().then(cfg => {
-        this._config = cfg;
-      });
-      this.$.restAPI.getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
-      });
-    },
-
-    get focusStart() {
-      return this.$.input.focusStart;
-    },
-
-    focus() {
-      this.$.input.focus();
-    },
-
-    clear() {
-      this.$.input.clear();
-    },
-
-    setText(text) {
-      this.$.input.setText(text);
-    },
-
-    getText() {
-      return this.$.input.text;
-    },
-
-    _handleInputCommit(e) {
-      this.fire('add', {value: e.detail.value});
-      this.$.input.focus();
-    },
-
-    _accountOrAnon(reviewer) {
-      return this.getUserName(this._config, reviewer, false);
-    },
-
-    _inputTextChanged(text) {
-      if (text.length && this.allowAnyInput) {
-        this.dispatchEvent(new CustomEvent(
-            'account-text-changed', {bubbles: true, composed: true}));
-      }
-    },
-
-    _makeSuggestion(reviewer) {
-      let name;
-      let value;
-      const generateStatusStr = function(account) {
-        return account.status ? '(' + account.status + ')' : '';
-      };
-      if (reviewer.account) {
-        // Reviewer is an account suggestion from getChangeSuggestedReviewers.
-        const reviewerName = this._accountOrAnon(reviewer.account);
-        const reviewerEmail = this._reviewerEmail(reviewer.account.email);
-        const reviewerStatus = generateStatusStr(reviewer.account);
-        name = [reviewerName, reviewerEmail, reviewerStatus]
-            .filter(p => p.length > 0).join(' ');
-        value = reviewer;
-      } else if (reviewer.group) {
-        // Reviewer is a group suggestion from getChangeSuggestedReviewers.
-        name = reviewer.group.name + ' (group)';
-        value = reviewer;
-      } else if (reviewer._account_id) {
-        // Reviewer is an account suggestion from getSuggestedAccounts.
-        const reviewerName = this._accountOrAnon(reviewer);
-        const reviewerEmail = this._reviewerEmail(reviewer.email);
-        const reviewerStatus = generateStatusStr(reviewer);
-        name = [reviewerName, reviewerEmail, reviewerStatus]
-            .filter(p => p.length > 0).join(' ');
-        value = {account: reviewer, count: 1};
-      }
-      return {name, value};
-    },
-
-    _getReviewerSuggestions(input) {
-      if (!this.change || !this.change._number || !this._loggedIn) {
-        return Promise.resolve([]);
-      }
-
-      const api = this.$.restAPI;
-      const xhr = this.allowAnyUser ?
-          api.getSuggestedAccounts(`cansee:${this.change._number} ${input}`) :
-          api.getChangeSuggestedReviewers(this.change._number, input);
-
-      return xhr.then(reviewers => {
-        if (!reviewers) { return []; }
-        if (!this.filter) {
-          return reviewers.map(this._makeSuggestion.bind(this));
-        }
-        return reviewers
-            .filter(this.filter)
-            .map(this._makeSuggestion.bind(this));
-      });
-    },
-
-    _reviewerEmail(email) {
-      if (typeof email !== 'undefined') {
-        return '<' + email + '>';
-      }
-
-      return '';
-    },
-  });
-})();
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
deleted file mode 100644
index 57bdd1d..0000000
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
+++ /dev/null
@@ -1,276 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-account-entry</title>
-<script src="/test/common-test-setup.js"></script>
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
-
-<link rel="import" href="gr-account-entry.html">
-
-<script>void(0);</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-account-entry></gr-account-entry>
-  </template>
-</test-fixture>
-
-<script>
-  suite('gr-account-entry tests', () => {
-    let sandbox;
-    let _nextAccountId = 0;
-    const makeAccount = function(opt_status) {
-      const accountId = ++_nextAccountId;
-      return {
-        _account_id: accountId,
-        name: 'name ' + accountId,
-        email: 'email ' + accountId,
-        status: opt_status,
-      };
-    };
-    let _nextAccountId2 = 0;
-    const makeAccount2 = function(opt_status) {
-      const accountId2 = ++_nextAccountId2;
-      return {
-        _account_id: accountId2,
-        email: 'email ' + accountId2,
-        status: opt_status,
-      };
-    };
-    let _nextAccountId3 = 0;
-    const makeAccount3 = function(opt_status) {
-      const accountId3 = ++_nextAccountId3;
-      return {
-        _account_id: accountId3,
-        name: 'name ' + accountId3,
-        status: opt_status,
-      };
-    };
-
-    let owner;
-    let existingReviewer1;
-    let existingReviewer2;
-    let suggestion1;
-    let suggestion2;
-    let suggestion3;
-    let element;
-
-    setup(done => {
-      owner = makeAccount();
-      existingReviewer1 = makeAccount();
-      existingReviewer2 = makeAccount();
-      suggestion1 = {account: makeAccount()};
-      suggestion2 = {account: makeAccount()};
-      suggestion3 = {
-        group: {
-          id: 'suggested group id',
-          name: 'suggested group',
-        },
-      };
-
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-      });
-
-      element = fixture('basic');
-      element.change = {
-        _number: 42,
-        owner,
-        reviewers: {
-          CC: [existingReviewer1],
-          REVIEWER: [existingReviewer2],
-        },
-      };
-      sandbox = sinon.sandbox.create();
-      return flush(done);
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('stubbed values for _getReviewerSuggestions', () => {
-      setup(() => {
-        stub('gr-rest-api-interface', {
-          getChangeSuggestedReviewers() {
-            const redundantSuggestion1 = {account: existingReviewer1};
-            const redundantSuggestion2 = {account: existingReviewer2};
-            const redundantSuggestion3 = {account: owner};
-            return Promise.resolve([redundantSuggestion1, redundantSuggestion2,
-              redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
-          },
-        });
-      });
-
-      test('_makeSuggestion formats account or group accordingly', () => {
-        let account = makeAccount();
-        const account2 = makeAccount2();
-        const account3 = makeAccount3();
-        let suggestion = element._makeSuggestion({account});
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '>',
-          value: {account},
-        });
-
-        const group = {name: 'test'};
-        suggestion = element._makeSuggestion({group});
-        assert.deepEqual(suggestion, {
-          name: group.name + ' (group)',
-          value: {group},
-        });
-
-        suggestion = element._makeSuggestion(account);
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '>',
-          value: {account, count: 1},
-        });
-
-        element._config = {
-          user: {
-            anonymous_coward_name: 'Anonymous Coward',
-          },
-        };
-        assert.deepEqual(element._accountOrAnon(account2), 'Anonymous');
-
-        account = makeAccount('OOO');
-
-        suggestion = element._makeSuggestion({account});
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '> (OOO)',
-          value: {account},
-        });
-
-        suggestion = element._makeSuggestion(account);
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '> (OOO)',
-          value: {account, count: 1},
-        });
-
-        sandbox.stub(element, '_reviewerEmail',
-            () => { return ''; });
-
-        suggestion = element._makeSuggestion(account3);
-        assert.deepEqual(suggestion, {
-          name: account3.name,
-          value: {account: account3, count: 1},
-        });
-      });
-
-      test('_reviewerEmail', () => {
-        assert.equal(
-            element._reviewerEmail('email@gerritreview.com'),
-            '<email@gerritreview.com>');
-        assert.equal(element._reviewerEmail(undefined), '');
-      });
-
-      test('_getReviewerSuggestions excludes owner+reviewers', done => {
-        element._getReviewerSuggestions().then(reviewers => {
-          // Default is no filtering.
-          assert.equal(reviewers.length, 6);
-
-          // Set up filter that only accepts suggestion1.
-          const accountId = suggestion1.account._account_id;
-          element.filter = function(suggestion) {
-            return suggestion.account &&
-                suggestion.account._account_id === accountId;
-          };
-
-          element._getReviewerSuggestions().then(reviewers => {
-            assert.deepEqual(reviewers, [element._makeSuggestion(suggestion1)]);
-          }).then(done);
-        });
-      });
-
-      test('_getReviewerSuggestions short circuits when logged out', () => {
-        // API call is already stubbed.
-        const xhrSpy = element.$.restAPI.getChangeSuggestedReviewers;
-        element._loggedIn = false;
-        return element._getReviewerSuggestions('').then(() => {
-          assert.isFalse(xhrSpy.called);
-          element._loggedIn = true;
-          return element._getReviewerSuggestions('').then(() => {
-            assert.isTrue(xhrSpy.called);
-          });
-        });
-      });
-    });
-
-    test('allowAnyUser', done => {
-      const suggestReviewerStub =
-          sandbox.stub(element.$.restAPI, 'getChangeSuggestedReviewers')
-          .returns(Promise.resolve([]));
-      const suggestAccountStub =
-          sandbox.stub(element.$.restAPI, 'getSuggestedAccounts')
-          .returns(Promise.resolve([]));
-
-      element._getReviewerSuggestions('').then(() => {
-        assert.isTrue(suggestReviewerStub.calledOnce);
-        assert.isTrue(suggestReviewerStub.calledWith(42, ''));
-        assert.isFalse(suggestAccountStub.called);
-        element.allowAnyUser = true;
-
-        element._getReviewerSuggestions('').then(() => {
-          assert.isTrue(suggestReviewerStub.calledOnce);
-          assert.isTrue(suggestAccountStub.calledOnce);
-          assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
-          done();
-        });
-      });
-    });
-    test('account-text-changed fired when input text changed and allowAnyInput',
-        () => {
-          // Spy on query, as that is called when _updateSuggestions proceeds.
-          const changeStub = sandbox.stub();
-          element.allowAnyInput = true;
-          sandbox.stub(element.$.restAPI, 'getChangeSuggestedReviewers')
-              .returns(Promise.resolve([]));
-          element.addEventListener('account-text-changed', changeStub);
-          element.$.input.text = 'a';
-          assert.isTrue(changeStub.calledOnce);
-          element.$.input.text = 'ab';
-          assert.isTrue(changeStub.calledTwice);
-        });
-
-    test('account-text-changed not fired when input text changed without ' +
-        'allowAnyUser', () => {
-          // Spy on query, as that is called when _updateSuggestions proceeds.
-      const changeStub = sandbox.stub();
-      sandbox.stub(element.$.restAPI, 'getChangeSuggestedReviewers')
-          .returns(Promise.resolve([]));
-      element.addEventListener('account-text-changed', changeStub);
-      element.$.input.text = 'a';
-      assert.isFalse(changeStub.called);
-    });
-
-    test('setText', () => {
-      // Spy on query, as that is called when _updateSuggestions proceeds.
-      const suggestSpy = sandbox.spy(element.$.input, 'query');
-      element.setText('test text');
-      flushAsynchronousOperations();
-
-      assert.equal(element.$.input.$.input.value, 'test text');
-      assert.isFalse(suggestSpy.called);
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index 15892c9..ee35583 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -36,6 +36,8 @@
 <link rel="import" href="../gr-change-requirements/gr-change-requirements.html">
 <link rel="import" href="../gr-commit-info/gr-commit-info.html">
 <link rel="import" href="../gr-reviewer-list/gr-reviewer-list.html">
+<script src="../../../scripts/gr-display-name-utils/gr-display-name-utils.js"></script>
+<script src="../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js"></script>
 
 <dom-module id="gr-change-metadata">
   <template>
@@ -172,9 +174,9 @@
               id="assigneeValue"
               placeholder="Set assignee..."
               accounts="{{_assignee}}"
-              change="[[change]]"
               readonly="[[_computeAssigneeReadOnly(_mutable, change)]]"
-              allow-any-user></gr-account-list>
+              suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]">
+          </gr-account-list>
         </span>
       </section>
       <section>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index 6ee5a24..62446e4 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -471,5 +471,12 @@
       // dom-if.
       this.$$('.topicEditableLabel').open();
     },
+
+    _getReviewerSuggestionsProvider(change) {
+      const provider = new GrReviewerSuggestionsProvider(this.$.restAPI,
+          change._number, true);
+      provider.init();
+      return provider;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
index 08a3ec3..38c4a96 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
@@ -32,9 +32,11 @@
 <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="../../shared/gr-storage/gr-storage.html">
-<link rel="import" href="../gr-account-list/gr-account-list.html">
+<link rel="import" href="../../shared/gr-account-list/gr-account-list.html">
 <link rel="import" href="../gr-label-scores/gr-label-scores.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<script src="../../../scripts/gr-display-name-utils/gr-display-name-utils.js"></script>
+<script src="../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js"></script>
 
 <dom-module id="gr-reply-dialog">
   <template>
@@ -165,11 +167,11 @@
               id="reviewers"
               accounts="{{_reviewers}}"
               removable-values="[[change.removable_reviewers]]"
-              change="[[change]]"
               filter="[[filterReviewerSuggestion]]"
               pending-confirmation="{{_reviewerPendingConfirmation}}"
               placeholder="Add reviewer..."
-              on-account-text-changed="_handleAccountTextEntry">
+              on-account-text-changed="_handleAccountTextEntry"
+              suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]">
           </gr-account-list>
         </div>
         <div class="peopleList">
@@ -177,12 +179,12 @@
           <gr-account-list
               id="ccs"
               accounts="{{_ccs}}"
-              change="[[change]]"
               filter="[[filterCCSuggestion]]"
               pending-confirmation="{{_ccPendingConfirmation}}"
               allow-any-input
               placeholder="Add CC..."
-              on-account-text-changed="_handleAccountTextEntry">
+              on-account-text-changed="_handleAccountTextEntry"
+              suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]">
           </gr-account-list>
         </div>
         <gr-overlay
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index 3b97439..6b7359f 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -895,5 +895,12 @@
     _sendDisabledChanged(sendDisabled) {
       this.dispatchEvent(new CustomEvent('send-disabled-changed'));
     },
+
+    _getReviewerSuggestionsProvider(change) {
+      const provider = new GrReviewerSuggestionsProvider(this.$.restAPI,
+          change._number, false);
+      provider.init();
+      return provider;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
index 7949002..dc18f8a 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html">
+<link rel="import" href="../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.html">
 <link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index 2ade782..f8288c6 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -58,7 +58,7 @@
     },
 
     behaviors: [
-      Gerrit.AnonymousNameBehavior,
+      Gerrit.DisplayNameBehavior,
     ],
 
     detached() {
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
index 397189a..2bd4a620 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -109,6 +109,9 @@
 
   const pending = [];
 
+  const loadedPlugins = [];
+  const detectedExtensions = [];
+
   const onError = function(oldOnError, msg, url, line, column, error) {
     if (oldOnError) {
       oldOnError(msg, url, line, column, error);
@@ -190,6 +193,7 @@
     reporter(...args) {
       const report = (this._isMetricsPluginLoaded() && !pending.length) ?
         this.defaultReporter : this.cachingReporter;
+      args.splice(4, 0, loadedPlugins, detectedExtensions);
       report.apply(this, args);
     },
 
@@ -199,16 +203,23 @@
      * @param {string} category
      * @param {string} eventName
      * @param {string|number} eventValue
+     * @param {Array} plugins
+     * @param {Array} extensions
      * @param {boolean|undefined} opt_noLog If true, the event will not be
      *     logged to the JS console.
      */
-    defaultReporter(type, category, eventName, eventValue, opt_noLog) {
+    defaultReporter(type, category, eventName, eventValue,
+        loadedPlugins, detectedExtensions, opt_noLog) {
       const detail = {
         type,
         category,
         name: eventName,
         value: eventValue,
       };
+      if (category === TIMING.CATEGORY_UI_LATENCY) {
+        detail.loadedPlugins = loadedPlugins;
+        detail.detectedExtensions = detectedExtensions;
+      }
       document.dispatchEvent(new CustomEvent(type, {detail}));
       if (opt_noLog) { return; }
       if (type === ERROR.TYPE && category === ERROR.CATEGORY) {
@@ -229,10 +240,13 @@
      * @param {string} category
      * @param {string} eventName
      * @param {string|number} eventValue
+     * @param {Array} plugins
+     * @param {Array} extensions
      * @param {boolean|undefined} opt_noLog If true, the event will not be
      *     logged to the JS console.
      */
-    cachingReporter(type, category, eventName, eventValue, opt_noLog) {
+    cachingReporter(type, category, eventName, eventValue,
+        plugins, extensions, opt_noLog) {
       if (type === ERROR.TYPE && category === ERROR.CATEGORY) {
         console.error(eventValue && eventValue.error || eventName);
       }
@@ -242,9 +256,11 @@
             this.reporter(...args);
           }
         }
-        this.reporter(type, category, eventName, eventValue, opt_noLog);
+        this.reporter(type, category, eventName, eventValue,
+            plugins, extensions, opt_noLog);
       } else {
-        pending.push([type, category, eventName, eventValue, opt_noLog]);
+        pending.push([type, category, eventName, eventValue,
+          plugins, extensions, opt_noLog]);
       }
     },
 
@@ -337,12 +353,16 @@
 
     reportExtension(name) {
       this.reporter(EXTENSION.TYPE, EXTENSION.DETECTED, name);
+      if (!detectedExtensions.includes(name)) {
+        detectedExtensions.push(name);
+      }
     },
 
     pluginLoaded(name) {
       if (name.startsWith('metrics-')) {
         this.timeEnd(TIMER.METRICS_PLUGIN_LOADED);
       }
+      loadedPlugins.push(name);
     },
 
     pluginsLoaded(pluginsList) {
@@ -487,7 +507,7 @@
 
     reportErrorDialog(message) {
       this.reporter(ERROR_DIALOG.TYPE, ERROR_DIALOG.CATEGORY,
-          'ErrorDialog: ' + message);
+          'ErrorDialog: ' + message, {error: new Error(message)});
     },
   });
 
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
index a0a25b3..805cef0 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
@@ -265,7 +265,7 @@
       test('pluginsLoaded reports time', () => {
         sandbox.stub(element, 'now').returns(42);
         element.pluginsLoaded();
-        assert.isTrue(element.defaultReporter.calledWithExactly(
+        assert.isTrue(element.defaultReporter.calledWith(
             'timing-report', 'UI Latency', 'PluginsLoaded', 42
         ));
       });
@@ -287,6 +287,19 @@
         assert.isTrue(element.defaultReporter.called);
       });
 
+      test('reports plugins in timing events', () => {
+        element.pluginsLoaded = [];
+        sandbox.stub(element, 'now').returns(42);
+        element.pluginLoaded('metrics-xyz1');
+        // element.pluginLoaded('foo');
+        element.time('timeAction');
+        element.timeEnd('timeAction');
+        assert.isTrue(element.defaultReporter.getCall(1).calledWith(
+            'timing-report', 'UI Latency', 'timeAction', 0,
+            ['metrics-xyz1']
+        ));
+      });
+
       test('reports if metrics plugin xyz is loaded', () => {
         element.pluginLoaded('metrics-xyz');
         assert.isTrue(element.defaultReporter.called);
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.html b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.html
index 06e354c..c4ae41b 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.html
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.html
@@ -16,7 +16,7 @@
 -->
 <link rel="import" href="/bower_components/polymer/polymer.html">
 
-<link rel="import" href="../../../behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html">
+<link rel="import" href="../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-search-bar/gr-search-bar.html">
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
index fed02d6..017310d 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
@@ -49,7 +49,7 @@
     },
 
     behaviors: [
-      Gerrit.AnonymousNameBehavior,
+      Gerrit.DisplayNameBehavior,
     ],
 
     attached() {
diff --git a/polygerrit-ui/app/elements/gr-app-it_test.html b/polygerrit-ui/app/elements/custom-dark-theme_test.html
similarity index 79%
copy from polygerrit-ui/app/elements/gr-app-it_test.html
copy to polygerrit-ui/app/elements/custom-dark-theme_test.html
index d62c082..4cf35f1 100644
--- a/polygerrit-ui/app/elements/gr-app-it_test.html
+++ b/polygerrit-ui/app/elements/custom-dark-theme_test.html
@@ -24,7 +24,7 @@
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
 <script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../test/common-test-setup.html"/>
-<link rel="import" href="gr-app.html">
+<link rel="import" href="./gr-app.html">
 
 <script>void(0);</script>
 
@@ -35,7 +35,7 @@
 </test-fixture>
 
 <script>
-  suite('gr-app integration tests', () => {
+  suite('gr-app custom dark theme tests', () => {
     let sandbox;
     let element;
 
@@ -63,13 +63,22 @@
         getVersion() { return Promise.resolve(42); },
         getLoggedIn() { return Promise.resolve(false); },
       });
+
+      window.localStorage.setItem('dark-theme', 'true');
+
       element = fixture('element');
 
-      const importSpy = sandbox.spy(element.$.externalStyle, '_import');
+      const importSpy = sandbox.spy(
+          element.$['app-element'].$.externalStyleForAll,
+          '_import');
+      const importForThemeSpy = sandbox.spy(
+          element.$['app-element'].$.externalStyleForTheme,
+          '_import');
       Gerrit.awaitPluginsLoaded().then(() => {
-        Promise.all(importSpy.returnValues).then(() => {
-          flush(done);
-        });
+        Promise.all(importSpy.returnValues.concat(importForThemeSpy.returnValues))
+            .then(() => {
+              flush(done);
+            });
       });
     });
 
@@ -77,21 +86,16 @@
       sandbox.restore();
     });
 
-    test('applies --primary-text-color', () => {
+    test('applies the right theme', () => {
       assert.equal(
           util.getComputedStyleValue('--primary-text-color', element),
-          '#F00BAA');
-    });
-
-    test('applies --header-background-color', () => {
+          'red');
       assert.equal(
           util.getComputedStyleValue('--header-background-color', element),
-          '#F01BAA');
-    });
-    test('applies --footer-background-color', () => {
+          'black');
       assert.equal(
           util.getComputedStyleValue('--footer-background-color', element),
-          '#F02BAA');
+          'yellow');
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/gr-app-it_test.html b/polygerrit-ui/app/elements/custom-light-theme_test.html
similarity index 82%
rename from polygerrit-ui/app/elements/gr-app-it_test.html
rename to polygerrit-ui/app/elements/custom-light-theme_test.html
index d62c082..e346af5 100644
--- a/polygerrit-ui/app/elements/gr-app-it_test.html
+++ b/polygerrit-ui/app/elements/custom-light-theme_test.html
@@ -35,7 +35,7 @@
 </test-fixture>
 
 <script>
-  suite('gr-app integration tests', () => {
+  suite('gr-app custom light theme tests', () => {
     let sandbox;
     let element;
 
@@ -63,13 +63,22 @@
         getVersion() { return Promise.resolve(42); },
         getLoggedIn() { return Promise.resolve(false); },
       });
+
+      window.localStorage.removeItem('dark-theme');
+
       element = fixture('element');
 
-      const importSpy = sandbox.spy(element.$.externalStyle, '_import');
+      const importSpy = sandbox.spy(
+          element.$['app-element'].$.externalStyleForAll,
+          '_import');
+      const importForThemeSpy = sandbox.spy(
+          element.$['app-element'].$.externalStyleForTheme,
+          '_import');
       Gerrit.awaitPluginsLoaded().then(() => {
-        Promise.all(importSpy.returnValues).then(() => {
-          flush(done);
-        });
+        Promise.all(importSpy.returnValues.concat(importForThemeSpy.returnValues))
+            .then(() => {
+              flush(done);
+            });
       });
     });
 
@@ -77,18 +86,13 @@
       sandbox.restore();
     });
 
-    test('applies --primary-text-color', () => {
+    test('applies the right theme', () => {
       assert.equal(
           util.getComputedStyleValue('--primary-text-color', element),
           '#F00BAA');
-    });
-
-    test('applies --header-background-color', () => {
       assert.equal(
           util.getComputedStyleValue('--header-background-color', element),
           '#F01BAA');
-    });
-    test('applies --footer-background-color', () => {
       assert.equal(
           util.getComputedStyleValue('--footer-background-color', element),
           '#F02BAA');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
index eb4123d..556395c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
@@ -54,7 +54,7 @@
       if (element.nodeName === '#text') {
         element = element.parentElement;
       }
-      while (!element.classList.contains('contentText')) {
+      while (element && !element.classList.contains('contentText')) {
         if (element.parentElement === null) {
           return target;
         }
@@ -80,7 +80,7 @@
         if (n === child) {
           break;
         }
-        if (n.childNodes && n.childNodes.length !== 0) {
+        if (n && n.childNodes && n.childNodes.length !== 0) {
           const arr = [];
           for (const childNode of n.childNodes) {
             arr.push(childNode);
@@ -101,7 +101,9 @@
      * @return {number} The length of the text.
      */
     _getLength(node) {
-      return node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length;
+      return node
+        ? node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length
+        : 0;
     },
   };
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js
index 9a9a6d7..f6f48b3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js
@@ -34,13 +34,13 @@
 
     getFocusStops() {
       return {
-        start: this.$.contextSelect,
+        start: this.$.diffPreferences.$.contextSelect,
         end: this.$.saveButton,
       };
     },
 
     resetFocus() {
-      this.$.contextSelect.focus();
+      this.$.diffPreferences.$.contextSelect.focus();
     },
 
     _computeHeaderClass(changed) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
index 83b6f55..6469382 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
@@ -20,6 +20,31 @@
 
 <dom-module id="gr-diff-selection">
   <template>
+    <style include="shared-styles">
+      /** Select and copy for Polymer 1*/
+      .contentWrapper ::content .content,
+      .contentWrapper ::content .contextControl,
+      .contentWrapper ::content .blame {
+        -webkit-user-select: none;
+        -moz-user-select: none;
+        -ms-user-select: none;
+        user-select: none;
+      }
+
+      :host-context(.selected-left:not(.selected-comment)) .contentWrapper ::content .side-by-side .left + .content .contentText,
+      :host-context(.selected-right:not(.selected-comment)) .contentWrapper ::content .side-by-side .right + .content .contentText,
+      :host-context(.selected-left:not(.selected-comment)) .contentWrapper ::content .unified .left.lineNum ~ .content:not(.both) .contentText,
+      :host-context(.selected-right:not(.selected-comment)) .contentWrapper ::content .unified .right.lineNum ~ .content .contentText,
+      :host-context(.selected-left.selected-comment) .contentWrapper ::content .side-by-side .left + .content .message,
+      :host-context(.selected-right.selected-comment) .contentWrapper ::content .side-by-side .right + .content .message :not(.collapsedContent),
+      :host-context(.selected-comment) .contentWrapper ::content .unified .message :not(.collapsedContent),
+      :host-context(.selected-blame) .contentWrapper ::content .blame {
+        -webkit-user-select: text;
+        -moz-user-select: text;
+        -ms-user-select: text;
+        user-select: text;
+      }
+    </style>
     <div class="contentWrapper">
       <slot></slot>
     </div>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
index 072a0d7..ff7593b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
@@ -73,7 +73,28 @@
       this._linesCache = getNewCache();
     },
 
+    _handleDownOnRangeComment(node) {
+      // Keep the original behavior in polymer 1
+      if (!window.POLYMER2) return false;
+      if (node &&
+          node.nodeName &&
+          node.nodeName.toLowerCase() === 'gr-comment-thread') {
+        this._setClasses([
+          SelectionClass.COMMENT,
+          node.commentSide === 'left' ?
+          SelectionClass.LEFT :
+          SelectionClass.RIGHT,
+        ]);
+        return true;
+      }
+      return false;
+    },
+
     _handleDown(e) {
+      // Handle the down event on comment thread in Polymer 2
+      const handled = this._handleDownOnRangeComment(e.target);
+      if (handled) return;
+
       const lineEl = this.diffBuilder.getLineElByChild(e.target);
       const blameSelected = this._elementDescendedFromClass(e.target, 'blame');
       if (!lineEl && !blameSelected) { return; }
@@ -140,6 +161,10 @@
     },
 
     _handleCopy(e) {
+      // Let the browser handle the copy event for polymer 2
+      // as selection across shadow DOM will be hard to process
+      if (window.POLYMER2) return;
+
       let commentSelected = false;
       const target = this._getCopyEventTarget(e);
       if (target.type === 'textarea') { return; }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index cbb653b..bc8af9d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -42,6 +42,7 @@
         max-width: var(--content-width, 80ch);
         white-space: normal;
       }
+
       .thread-group {
         display: block;
         max-width: var(--content-width, 80ch);
@@ -307,7 +308,8 @@
         background: linear-gradient(to right bottom, #FFD1A4 0%, #FFD1A4 50%, #E0F2F1 50%, #E0F2F1 100%);
       }
 
-      /** Select to copy */
+      /** BEGIN: Select and copy for Polymer 2 */
+      /** Below was copied and modified from the original css in gr-diff-selection.html */
       .content,
       .contextControl,
       .blame {
@@ -331,6 +333,16 @@
         user-select: text;
       }
 
+      /** Make comments selectable */
+      .selected-left ::slotted(gr-comment-thread[comment-side=left]),
+      .selected-right ::slotted(gr-comment-thread[comment-side=right]) {
+        -webkit-user-select: text;
+        -moz-user-select: text;
+        -ms-user-select: text;
+        user-select: text;
+      }
+      /** END: Select and copy for Polymer 2 */
+
       .whitespace-change-only-message {
         background-color: var(--diff-context-control-background-color);
         border: 1px solid var(--diff-context-control-border-color);
diff --git a/polygerrit-ui/app/elements/gr-app-element.html b/polygerrit-ui/app/elements/gr-app-element.html
index 46c4502..a42622e 100644
--- a/polygerrit-ui/app/elements/gr-app-element.html
+++ b/polygerrit-ui/app/elements/gr-app-element.html
@@ -225,7 +225,8 @@
         config="[[_serverConfig]]">
     </gr-plugin-host>
     <gr-lib-loader id="libLoader"></gr-lib-loader>
-    <gr-external-style id="externalStyle" name="app-theme"></gr-external-style>
+    <gr-external-style id="externalStyleForAll" name="app-theme"></gr-external-style>
+    <gr-external-style id="externalStyleForTheme" name="[[getThemeEndpoint()]]"></gr-external-style>
   </template>
   <script src="gr-app-element.js" crossorigin="anonymous"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
index 01da895..3c146f8 100644
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -460,5 +460,12 @@
 
       return false;
     },
+
+    getThemeEndpoint() {
+      // For now, we only have dark mode and light mode
+      return window.localStorage.getItem('dark-theme') ?
+        'app-theme-dark' :
+        'app-theme-light';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.js b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.js
index 3959186..d1f8e56 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.js
@@ -30,8 +30,8 @@
    * @param {string} text
    * @param {string} url
    */
-  GrAdminApi.prototype.addMenuLink = function(text, url) {
-    this._menuLinks.push({text, url});
+  GrAdminApi.prototype.addMenuLink = function(text, url, opt_capability) {
+    this._menuLinks.push({text, url, capability: opt_capability || null});
   };
 
   GrAdminApi.prototype.getMenuLinks = function() {
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html
index 6883e7e..9cdcf76 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html
@@ -56,7 +56,15 @@
       adminApi.addMenuLink('text', 'url');
       const links = adminApi.getMenuLinks();
       assert.equal(links.length, 1);
-      assert.deepEqual(links[0], {text: 'text', url: 'url'});
+      assert.deepEqual(links[0], {text: 'text', url: 'url', capability: null});
+    });
+
+    test('addMenuLinkWithCapability', () => {
+      adminApi.addMenuLink('text', 'url', 'capability');
+      const links = adminApi.getMenuLinks();
+      assert.equal(links.length, 1);
+      assert.deepEqual(links[0],
+          {text: 'text', url: 'url', capability: 'capability'});
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.html
similarity index 79%
rename from polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
rename to polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.html
index 03fc606..ae656fd 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.html
@@ -15,12 +15,11 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html">
 <link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-autocomplete/gr-autocomplete.html">
+<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-account-entry">
   <template>
@@ -36,14 +35,13 @@
         borderless="[[borderless]]"
         placeholder="[[placeholder]]"
         threshold="[[suggestFrom]]"
-        query="[[query]]"
+        query="[[querySuggestions]]"
         allow-non-suggested-values="[[allowAnyInput]]"
         on-commit="_handleInputCommit"
         clear-on-commit
         warn-uncommitted
         text="{{_inputText}}">
     </gr-autocomplete>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-account-entry.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js
new file mode 100644
index 0000000..63bd252
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js
@@ -0,0 +1,102 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  /**
+   * gr-account-entry is an element for entering account
+   * and/or group with autocomplete support.
+   */
+  Polymer({
+    is: 'gr-account-entry',
+    _legacyUndefinedCheck: true,
+
+    /**
+     * Fired when an account is entered.
+     *
+     * @event add
+     */
+
+    /**
+     * When allowAnyInput is true, account-text-changed is fired when input text
+     * changed. This is needed so that the reply dialog's save button can be
+     * enabled for arbitrary cc's, which don't need a 'commit'.
+     *
+     * @event account-text-changed
+     */
+    properties: {
+      allowAnyInput: Boolean,
+      borderless: Boolean,
+      placeholder: String,
+
+      // suggestFrom = 0 to enable default suggestions.
+      suggestFrom: {
+        type: Number,
+        value: 0,
+      },
+
+      /** @type {!function(string): !Promise<Array<{name, value}>>} */
+      querySuggestions: {
+        type: Function,
+        notify: true,
+        value() {
+          return input => Promise.resolve([]);
+        },
+      },
+
+      _config: Object,
+      /** The value of the autocomplete entry. */
+      _inputText: {
+        type: String,
+        observer: '_inputTextChanged',
+      },
+
+    },
+
+    get focusStart() {
+      return this.$.input.focusStart;
+    },
+
+    focus() {
+      this.$.input.focus();
+    },
+
+    clear() {
+      this.$.input.clear();
+    },
+
+    setText(text) {
+      this.$.input.setText(text);
+    },
+
+    getText() {
+      return this.$.input.text;
+    },
+
+    _handleInputCommit(e) {
+      this.fire('add', {value: e.detail.value});
+      this.$.input.focus();
+    },
+
+    _inputTextChanged(text) {
+      if (text.length && this.allowAnyInput) {
+        this.dispatchEvent(new CustomEvent(
+                'account-text-changed', {bubbles: true, composed: true}));
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.html b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.html
new file mode 100644
index 0000000..59792a7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.html
@@ -0,0 +1,113 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-account-entry</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="gr-account-entry.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-account-entry></gr-account-entry>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-account-entry tests', () => {
+    let sandbox;
+
+    const suggestion1 = {
+      email: 'email1@example.com',
+      _account_id: 1,
+      some_property: 'value',
+    };
+    const suggestion2 = {
+      email: 'email2@example.com',
+      _account_id: 2,
+    };
+    const suggestion3 = {
+      email: 'email25@example.com',
+      _account_id: 25,
+      some_other_property: 'other value',
+    };
+
+    setup(done => {
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+      return flush(done);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('stubbed values for querySuggestions', () => {
+      setup(() => {
+        element.querySuggestions = input => {
+          return Promise.resolve([
+            suggestion1,
+            suggestion2,
+            suggestion3,
+          ]);
+        };
+      });
+    });
+
+    test('account-text-changed fired when input text changed and allowAnyInput',
+        () => {
+          // Spy on query, as that is called when _updateSuggestions proceeds.
+          const changeStub = sandbox.stub();
+          element.allowAnyInput = true;
+          element.querySuggestions = input => Promise.resolve([]);
+          element.addEventListener('account-text-changed', changeStub);
+          element.$.input.text = 'a';
+          assert.isTrue(changeStub.calledOnce);
+          element.$.input.text = 'ab';
+          assert.isTrue(changeStub.calledTwice);
+        });
+
+    test('account-text-changed not fired when input text changed without ' +
+        'allowAnyInput', () => {
+          // Spy on query, as that is called when _updateSuggestions proceeds.
+      const changeStub = sandbox.stub();
+      element.querySuggestions = input => Promise.resolve([]);
+      element.addEventListener('account-text-changed', changeStub);
+      element.$.input.text = 'a';
+      assert.isFalse(changeStub.called);
+    });
+
+    test('setText', () => {
+      // Spy on query, as that is called when _updateSuggestions proceeds.
+      const suggestSpy = sandbox.spy(element.$.input, 'query');
+      element.setText('test text');
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.input.$.input.value, 'test text');
+      assert.isFalse(suggestSpy.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
index 9d0782f..f807a74 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
@@ -15,7 +15,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html">
+<link rel="import" href="../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.html">
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 <link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/shared-styles.html">
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
index 88df33b..a778e8b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
@@ -52,7 +52,7 @@
     },
 
     behaviors: [
-      Gerrit.AnonymousNameBehavior,
+      Gerrit.DisplayNameBehavior,
       Gerrit.TooltipBehavior,
     ],
 
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.html
similarity index 91%
rename from polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
rename to polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.html
index 31c1be5..c793c07 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.html
@@ -17,7 +17,7 @@
 
 <link rel="import" href="/bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
-<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
+<link rel="import" href="../gr-account-chip/gr-account-chip.html">
 <link rel="import" href="../gr-account-entry/gr-account-entry.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -56,7 +56,7 @@
             account="[[account]]"
             class$="[[_computeChipClass(account)]]"
             data-account-id$="[[account._account_id]]"
-            removable="[[_computeRemovable(account)]]"
+            removable="[[_computeRemovable(account, readonly)]]"
             on-keydown="_handleChipKeydown"
             tabindex="-1">
         </gr-account-chip>
@@ -67,13 +67,13 @@
         hidden$="[[_computeEntryHidden(maxCount, accounts.*, readonly)]]"
         id="entry"
         change="[[change]]"
-        filter="[[filter]]"
         placeholder="[[placeholder]]"
         on-add="_handleAdd"
         on-input-keydown="_handleInputKeydown"
         allow-any-input="[[allowAnyInput]]"
-        allow-any-user="[[allowAnyUser]]">
+        query-suggestions="[[_querySuggestions]]">
     </gr-account-entry>
+    <slot></slot>
   </template>
   <script src="gr-account-list.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
similarity index 79%
rename from polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
rename to polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
index 479fee2..9edc9c8 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
@@ -19,6 +19,24 @@
 
   const VALID_EMAIL_ALERT = 'Please input a valid email.';
 
+  const Defs = {};
+
+  /**
+   * @typedef {{
+   *   name: string,
+   *   value: Object,
+   * }}
+   */
+  Defs.GrSuggestionItem;
+
+  /**
+   * @typedef {{
+   *    getSuggestions: function(string): Promise<Array<Object>>,
+   *    makeSuggestionItem: function(Object): Defs.GrSuggestionItem,
+   * }}
+   */
+  Defs.GrSuggestionsProvider;
+
   Polymer({
     is: 'gr-account-list',
     _legacyUndefinedCheck: true,
@@ -38,6 +56,19 @@
       change: Object,
       filter: Function,
       placeholder: String,
+      disabled: {
+        type: Function,
+        value: false,
+      },
+
+      /**
+       * Returns suggestions and convert them to list item
+       * @type {Defs.GrSuggestionsProvider}
+       */
+      suggestionsProvider: {
+        type: Object,
+      },
+
       /**
        * Needed for template checking since value is initially set to null.
        * @type {?Object} */
@@ -50,21 +81,6 @@
         type: Boolean,
         value: false,
       },
-
-      /**
-       * When true, the account-entry autocomplete uses the account suggest API
-       * endpoint, which suggests any account in that Gerrit instance (and does
-       * not suggest groups).
-       *
-       * When false/undefined, account-entry uses the suggest_reviewers API
-       * endpoint, which suggests any account or group in that Gerrit instance
-       * that is not already a reviewer (or is not CCed) on that change.
-       */
-      allowAnyUser: {
-        type: Boolean,
-        value: false,
-      },
-
       /**
        * When true, allows for non-suggested inputs to be added.
        */
@@ -82,6 +98,16 @@
         type: Number,
         value: 0,
       },
+
+      /** Returns suggestion items
+      * @type {!function(string): Promise<Array<Defs.GrSuggestionItem>>}
+      */
+      _querySuggestions: {
+        type: Function,
+        value() {
+          return this._getSuggestions.bind(this);
+        },
+      },
     },
 
     behaviors: [
@@ -103,31 +129,46 @@
       return this.$.entry.focusStart;
     },
 
-    _handleAdd(e) {
-      this._addReviewer(e.detail.value);
+    _getSuggestions(input) {
+      const provider = this.suggestionsProvider;
+      if (!provider) {
+        return Promise.resolve([]);
+      }
+      return provider.getSuggestions(input).then(suggestions => {
+        if (!suggestions) { return []; }
+        if (this.filter) {
+          suggestions = suggestions.filter(this.filter);
+        }
+        return suggestions.map(suggestion =>
+            provider.makeSuggestionItem(suggestion));
+      });
     },
 
-    _addReviewer(reviewer) {
+    _handleAdd(e) {
+      this._addAccountItem(e.detail.value);
+    },
+
+    _addAccountItem(item) {
       // Append new account or group to the accounts property. We add our own
       // internal properties to the account/group here, so we clone the object
       // to avoid cluttering up the shared change object.
-      if (reviewer.account) {
+      if (item.account) {
         const account =
-            Object.assign({}, reviewer.account, {_pendingAdd: true});
+            Object.assign({}, item.account, {_pendingAdd: true});
         this.push('accounts', account);
-      } else if (reviewer.group) {
-        if (reviewer.confirm) {
-          this.pendingConfirmation = reviewer;
+      } else if (item.group) {
+        if (item.confirm) {
+          this.pendingConfirmation = item;
           return;
         }
-        const group = Object.assign({}, reviewer.group,
+        const group = Object.assign({}, item.group,
             {_pendingAdd: true, _group: true});
         this.push('accounts', group);
       } else if (this.allowAnyInput) {
-        if (!reviewer.includes('@')) {
+        if (!item.includes('@')) {
           // Repopulate the input with what the user tried to enter and have
           // a toast tell them why they can't enter it.
-          this.$.entry.setText(reviewer);
+          this.$.entry.setText(item);
           this.dispatchEvent(new CustomEvent('show-alert', {
             detail: {message: VALID_EMAIL_ALERT},
             bubbles: true,
@@ -135,7 +176,7 @@
           }));
           return false;
         } else {
-          const account = {email: reviewer, _pendingAdd: true};
+          const account = {email: item, _pendingAdd: true};
           this.push('accounts', account);
         }
       }
@@ -173,8 +214,8 @@
       return a === b;
     },
 
-    _computeRemovable(account) {
-      if (this.readonly) { return false; }
+    _computeRemovable(account, readonly) {
+      if (readonly) { return false; }
       if (this.removableValues) {
         for (let i = 0; i < this.removableValues.length; i++) {
           if (this._accountMatches(this.removableValues[i], account)) {
@@ -193,7 +234,9 @@
     },
 
     _removeAccount(toRemove) {
-      if (!toRemove || !this._computeRemovable(toRemove)) { return; }
+      if (!toRemove || !this._computeRemovable(toRemove, this.readonly)) {
+        return;
+      }
       for (let i = 0; i < this.accounts.length; i++) {
         let matches;
         const account = this.accounts[i];
@@ -277,7 +320,7 @@
     submitEntryText() {
       const text = this.$.entry.getText();
       if (!text.length) { return true; }
-      const wasSubmitted = this._addReviewer(text);
+      const wasSubmitted = this._addAccountItem(text);
       if (wasSubmitted) { this.$.entry.clear(); }
       return wasSubmitted;
     },
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
similarity index 77%
rename from polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
rename to polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
index d32c269..22e3a3d 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
@@ -35,6 +35,15 @@
 </test-fixture>
 
 <script>
+  class MockSuggestionsProvider {
+    getSuggestions(input) {
+      return Promise.resolve([]);
+    }
+
+    makeSuggestionItem(item) {
+      return item;
+    }
+  }
   suite('gr-account-list tests', () => {
     let _nextAccountId = 0;
     const makeAccount = function() {
@@ -51,10 +60,11 @@
       };
     };
 
-    let existingReviewer1;
-    let existingReviewer2;
+    let existingAccount1;
+    let existingAccount2;
     let sandbox;
     let element;
+    let suggestionsProvider;
 
     function getChips() {
       return Polymer.dom(element.root).querySelectorAll('gr-account-chip');
@@ -62,14 +72,16 @@
 
     setup(() => {
       sandbox = sinon.sandbox.create();
-      existingReviewer1 = makeAccount();
-      existingReviewer2 = makeAccount();
+      existingAccount1 = makeAccount();
+      existingAccount2 = makeAccount();
 
       stub('gr-rest-api-interface', {
         getConfig() { return Promise.resolve({}); },
       });
       element = fixture('basic');
-      element.accounts = [existingReviewer1, existingReviewer2];
+      element.accounts = [existingAccount1, existingAccount2];
+      suggestionsProvider = new MockSuggestionsProvider();
+      element.suggestionsProvider = suggestionsProvider;
     });
 
     teardown(() => {
@@ -109,7 +121,7 @@
       assert.isTrue(chips[2].classList.contains('pendingAdd'));
 
       // Removed accounts are taken out of the list.
-      element.fire('remove', {account: existingReviewer1});
+      element.fire('remove', {account: existingAccount1});
       flushAsynchronousOperations();
       chips = getChips();
       assert.equal(chips.length, 2);
@@ -117,7 +129,7 @@
       assert.isTrue(chips[1].classList.contains('pendingAdd'));
 
       // Invalid remove is ignored.
-      element.fire('remove', {account: existingReviewer1});
+      element.fire('remove', {account: existingAccount1});
       element.fire('remove', {account: newAccount});
       flushAsynchronousOperations();
       chips = getChips();
@@ -147,6 +159,52 @@
       assert.isFalse(chips[0].classList.contains('pendingAdd'));
     });
 
+    test('_getSuggestions uses filter correctly', done => {
+      const originalSuggestions = [
+        {
+          email: 'abc@example.com',
+          text: 'abcd',
+          _account_id: 3,
+        },
+        {
+          email: 'qwe@example.com',
+          text: 'qwer',
+          _account_id: 1,
+        },
+        {
+          email: 'xyz@example.com',
+          text: 'aaaaa',
+          _account_id: 25,
+        },
+      ];
+      sandbox.stub(suggestionsProvider, 'getSuggestions')
+          .returns(Promise.resolve(originalSuggestions));
+      sandbox.stub(suggestionsProvider, 'makeSuggestionItem', suggestion => {
+        return {
+          name: suggestion.email,
+          value: suggestion._account_id,
+        };
+      });
+
+
+      element._getSuggestions().then(suggestions => {
+        // Default is no filtering.
+        assert.equal(suggestions.length, 3);
+
+        // Set up filter that only accepts suggestion1.
+        const accountId = originalSuggestions[0]._account_id;
+        element.filter = function(suggestion) {
+          return suggestion._account_id === accountId;
+        };
+
+        element._getSuggestions().then(suggestions => {
+          assert.deepEqual(suggestions,
+              [{name: originalSuggestions[0].email,
+                value: originalSuggestions[0]._account_id}]);
+        }).then(done);
+      });
+    });
+
     test('_computeChipClass', () => {
       const account = makeAccount();
       assert.equal(element._computeChipClass(account), '');
@@ -163,18 +221,18 @@
       newAccount._pendingAdd = true;
       element.readonly = false;
       element.removableValues = [];
-      assert.isFalse(element._computeRemovable(existingReviewer1));
-      assert.isTrue(element._computeRemovable(newAccount));
+      assert.isFalse(element._computeRemovable(existingAccount1, false));
+      assert.isTrue(element._computeRemovable(newAccount, false));
 
 
-      element.removableValues = [existingReviewer1];
-      assert.isTrue(element._computeRemovable(existingReviewer1));
-      assert.isTrue(element._computeRemovable(newAccount));
-      assert.isFalse(element._computeRemovable(existingReviewer2));
+      element.removableValues = [existingAccount1];
+      assert.isTrue(element._computeRemovable(existingAccount1, false));
+      assert.isTrue(element._computeRemovable(newAccount, false));
+      assert.isFalse(element._computeRemovable(existingAccount2, false));
 
       element.readonly = true;
-      assert.isFalse(element._computeRemovable(existingReviewer1));
-      assert.isFalse(element._computeRemovable(newAccount));
+      assert.isFalse(element._computeRemovable(existingAccount1, true));
+      assert.isFalse(element._computeRemovable(newAccount, true));
     });
 
     test('submitEntryText', () => {
@@ -293,13 +351,40 @@
       assert.isTrue(element.$.entry.hasAttribute('hidden'));
     });
 
-    suite('allowAnyInput', () => {
-      let entry;
+    test('enter text calls suggestions provider', done => {
+      const suggestions = [
+        {
+          email: 'abc@example.com',
+          text: 'abcd',
+        },
+        {
+          email: 'qwe@example.com',
+          text: 'qwer',
+        },
+      ];
+      const getSuggestionsStub =
+          sandbox.stub(suggestionsProvider, 'getSuggestions')
+              .returns(Promise.resolve(suggestions));
 
+      const makeSuggestionItemStub =
+          sandbox.stub(suggestionsProvider, 'makeSuggestionItem', item => item);
+
+      const input = element.$.entry.$.input;
+
+      input.text = 'newTest';
+      MockInteractions.focus(input.$.input);
+      input.noDebounce = true;
+      flushAsynchronousOperations();
+      flush(() => {
+        assert.isTrue(getSuggestionsStub.calledOnce);
+        assert.equal(getSuggestionsStub.lastCall.args[0], 'newTest');
+        assert.equal(makeSuggestionItemStub.getCalls().length, 2);
+        done();
+      });
+    });
+
+    suite('allowAnyInput', () => {
       setup(() => {
-        entry = element.$.entry;
-        sandbox.stub(entry, '_getReviewerSuggestions');
-        sandbox.stub(entry.$.input, '_updateSuggestions');
         element.allowAnyInput = true;
       });
 
@@ -334,7 +419,6 @@
     suite('keyboard interactions', () => {
       test('backspace at text input start removes last account', () => {
         const input = element.$.entry.$.input;
-        sandbox.stub(element.$.entry, '_getReviewerSuggestions');
         sandbox.stub(input, '_updateSuggestions');
         sandbox.stub(element, '_computeRemovable').returns(true);
         // Next line is a workaround for Firefix not moving cursor
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
index f747719..498dfb8 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
@@ -232,6 +232,16 @@
       #deleteBtn.showDeleteButtons {
         display: block;
       }
+
+      /** Disable select for the caret and actions */
+      .actions,
+      .show-hide {
+        -webkit-user-select: none;
+        -moz-user-select: none;
+        -ms-user-select: none;
+        user-select: none;
+      }
+
     </style>
     <div id="container" class="container">
       <div class="header" id="header" on-tap="_handleToggleCollapsed">
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
index e885bd4..f494325 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
@@ -51,7 +51,15 @@
   };
 
   GrPluginRestApi.prototype.fetchJSON = function(req) {
-    return getRestApi()._fetchJSON(req);
+    // TODO(dhruvsri): find better implementation for fetchJSON
+    const api = getRestApi();
+    let fetchJSON;
+    if (api._fetchJSON) {
+      fetchJSON = api._fetchJSON.bind(api);
+    } else {
+      fetchJSON = api._restApiHelper.fetchJSON.bind(api._restApiHelper);
+    }
+    return fetchJSON(req);
   };
 
   GrPluginRestApi.prototype.getRepos =
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
index 87ea02b..7461ac4 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
@@ -29,6 +29,7 @@
 
 <dom-module id="gr-rest-api-interface">
   <!-- NB: Order is important, because of namespaced classes. -->
+  <script src="gr-rest-apis/gr-rest-api-helper.js"></script>
   <script src="gr-auth.js"></script>
   <script src="gr-reviewer-updates-parser.js"></script>
   <script src="gr-rest-api-interface.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 9b63f75..417fa23 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -29,34 +29,6 @@
 
   /**
    * @typedef {{
-   *    url: string,
-   *    fetchOptions: (Object|null|undefined),
-   *    anonymizedUrl: (string|undefined),
-   * }}
-   */
-  Defs.FetchRequest;
-
-  /**
-   * Object to describe a request for passing into _fetchJSON or _fetchRawJSON.
-   * - url is the URL for the request (excluding get params)
-   * - errFn is a function to invoke when the request fails.
-   * - cancelCondition is a function that, if provided and returns true, will
-   *     cancel the response after it resolves.
-   * - params is a key-value hash to specify get params for the request URL.
-   * @typedef {{
-   *    url: string,
-   *    errFn: (function(?Response, string=)|null|undefined),
-   *    cancelCondition: (function()|null|undefined),
-   *    params: (Object|null|undefined),
-   *    fetchOptions: (Object|null|undefined),
-   *    anonymizedUrl: (string|undefined),
-   *    reportUrlAsIs: (boolean|undefined),
-   * }}
-   */
-  Defs.FetchJSONRequest;
-
-  /**
-   * @typedef {{
    *   changeNum: (string|number),
    *   endpoint: string,
    *   patchNum: (string|number|null|undefined),
@@ -121,7 +93,6 @@
   const MAX_PROJECT_RESULTS = 25;
   const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 900;
   const PARENT_PATCH_NUM = 'PARENT';
-  const FAILED_TO_FETCH_ERROR = 'Failed to fetch';
 
   const Requests = {
     SEND_DIFF_DRAFT: 'sendDiffDraft',
@@ -135,60 +106,6 @@
   const ANONYMIZED_REVISION_BASE_URL = ANONYMIZED_CHANGE_BASE_URL +
       '/revisions/*';
 
-  /**
-   * Wrapper around Map for caching server responses. Site-based so that
-   * changes to CANONICAL_PATH will result in a different cache going into
-   * effect.
-   */
-  class SiteBasedCache {
-    constructor() {
-      // Container of per-canonical-path caches.
-      this._data = new Map();
-      if (window.INITIAL_DATA != undefined) {
-        // Put all data shipped with index.html into the cache. This makes it
-        // so that we spare more round trips to the server when the app loads
-        // initially.
-        Object
-            .entries(window.INITIAL_DATA)
-            .forEach(e => this._cache().set(e[0], e[1]));
-      }
-    }
-
-    // Returns the cache for the current canonical path.
-    _cache() {
-      if (!this._data.has(window.CANONICAL_PATH)) {
-        this._data.set(window.CANONICAL_PATH, new Map());
-      }
-      return this._data.get(window.CANONICAL_PATH);
-    }
-
-    has(key) {
-      return this._cache().has(key);
-    }
-
-    get(key) {
-      return this._cache().get(key);
-    }
-
-    set(key, value) {
-      this._cache().set(key, value);
-    }
-
-    delete(key) {
-      this._cache().delete(key);
-    }
-
-    invalidatePrefix(prefix) {
-      const newMap = new Map();
-      for (const [key, value] of this._cache().entries()) {
-        if (!key.startsWith(prefix)) {
-          newMap.set(key, value);
-        }
-      }
-      this._data.set(window.CANONICAL_PATH, newMap);
-    }
-  }
-
   Polymer({
     is: 'gr-rest-api-interface',
     _legacyUndefinedCheck: true,
@@ -235,7 +152,7 @@
       },
       _sharedFetchPromises: {
         type: Object,
-        value: {}, // Intentional to share the object across instances.
+        value: new FetchPromisesCache(), // Shared across instances.
       },
       _pendingRequests: {
         type: Object,
@@ -260,132 +177,48 @@
 
     JSON_PREFIX,
 
-    /**
-     * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
-     * with timing and logging.
-     * @param {Defs.FetchRequest} req
-     */
-    _fetch(req) {
-      const start = Date.now();
-      const xhr = this._auth.fetch(req.url, req.fetchOptions);
+    created() {
+      /* Polymer 1 and Polymer 2 have slightly different lifecycle.
+      * Differences are not very well documented (see
+      * https://github.com/Polymer/old-docs-site/issues/2322).
+      * In Polymer 1, created() is called when properties values is not set
+      * and ready() is always called later, even if element is not added
+      * to a DOM. I.e. in Polymer 1 _cache and other properties are undefined,
+      * while in Polymer 2 they are set to default values.
+      * In Polymer 2, created() is called after properties values set and
+      * ready() is called only after element is attached to a DOM.
+      * There are several places in the code, where element is created with
+      * document.createElement('gr-rest-api-interface') and is not added
+      * to a DOM.
+      * In such cases, Polymer 1 calls both created() and ready() methods,
+      * but Polymer 2 calls only created() method.
+      * To workaround these differences, we should try to create _restApiHelper
+      * in both methods.
+      */
+      //
 
-      // Log the call after it completes.
-      xhr.then(res => this._logCall(req, start, res.status));
-
-      // Return the XHR directly (without the log).
-      return xhr;
+      this._initRestApiHelper();
     },
 
-    /**
-     * Log information about a REST call. Because the elapsed time is determined
-     * by this method, it should be called immediately after the request
-     * finishes.
-     * @param {Defs.FetchRequest} req
-     * @param {number} startTime the time that the request was started.
-     * @param {number} status the HTTP status of the response. The status value
-     *     is used here rather than the response object so there is no way this
-     *     method can read the body stream.
-     */
-    _logCall(req, startTime, status) {
-      const method = (req.fetchOptions && req.fetchOptions.method) ?
-          req.fetchOptions.method : 'GET';
-      const endTime = Date.now();
-      const elapsed = (endTime - startTime);
-      const startAt = new Date(startTime);
-      const endAt = new Date(endTime);
-      console.log([
-        'HTTP',
-        status,
-        method,
-        elapsed + 'ms',
-        req.anonymizedUrl || req.url,
-        `(${startAt.toISOString()}, ${endAt.toISOString()})`,
-      ].join(' '));
-      if (req.anonymizedUrl) {
-        this.fire('rpc-log',
-            {status, method, elapsed, anonymizedUrl: req.anonymizedUrl});
+    ready() {
+      // See comments in created()
+      this._initRestApiHelper();
+    },
+
+    _initRestApiHelper() {
+      if (this._restApiHelper) {
+        return;
+      }
+      if (this._cache && this._auth && this._sharedFetchPromises
+          && this._credentialCheck) {
+        this._restApiHelper = new GrRestApiHelper(this._cache, this._auth,
+            this._sharedFetchPromises, this._credentialCheck, this);
       }
     },
 
-    /**
-     * Fetch JSON from url provided.
-     * Returns a Promise that resolves to a native Response.
-     * Doesn't do error checking. Supports cancel condition. Performs auth.
-     * Validates auth expiry errors.
-     * @param {Defs.FetchJSONRequest} req
-     */
-    _fetchRawJSON(req) {
-      const urlWithParams = this._urlWithParams(req.url, req.params);
-      const fetchReq = {
-        url: urlWithParams,
-        fetchOptions: req.fetchOptions,
-        anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl,
-      };
-      return this._fetch(fetchReq).then(res => {
-        if (req.cancelCondition && req.cancelCondition()) {
-          res.body.cancel();
-          return;
-        }
-        return res;
-      }).catch(err => {
-        const isLoggedIn = !!this._cache.get('/accounts/self/detail');
-        if (isLoggedIn && err && err.message === FAILED_TO_FETCH_ERROR) {
-          this.checkCredentials();
-        } else {
-          if (req.errFn) {
-            req.errFn.call(undefined, null, err);
-          } else {
-            this.fire('network-error', {error: err});
-          }
-        }
-        throw err;
-      });
-    },
-
-    /**
-     * Fetch JSON from url provided.
-     * Returns a Promise that resolves to a parsed response.
-     * Same as {@link _fetchRawJSON}, plus error handling.
-     * @param {Defs.FetchJSONRequest} req
-     */
-    _fetchJSON(req) {
-      req = this._addAcceptJsonHeader(req);
-      return this._fetchRawJSON(req).then(response => {
-        if (!response) {
-          return;
-        }
-        if (!response.ok) {
-          if (req.errFn) {
-            req.errFn.call(null, response);
-            return;
-          }
-          this.fire('server-error', {request: req, response});
-          return;
-        }
-        return response && this.getResponseObject(response);
-      });
-    },
-
-    /**
-     * @param {string} url
-     * @param {?Object|string=} opt_params URL params, key-value hash.
-     * @return {string}
-     */
-    _urlWithParams(url, opt_params) {
-      if (!opt_params) { return this.getBaseUrl() + url; }
-
-      const params = [];
-      for (const p in opt_params) {
-        if (!opt_params.hasOwnProperty(p)) { continue; }
-        if (opt_params[p] == null) {
-          params.push(encodeURIComponent(p));
-          continue;
-        }
-        for (const value of [].concat(opt_params[p])) {
-          params.push(`${encodeURIComponent(p)}=${encodeURIComponent(value)}`);
-        }
-      }
-      return this.getBaseUrl() + url + '?' + params.join('&');
+    _fetchSharedCacheURL(req) {
+      // Cache is shared across instances
+      return this._restApiHelper.fetchCacheURL(req);
     },
 
     /**
@@ -393,45 +226,7 @@
      * @return {?}
      */
     getResponseObject(response) {
-      return this._readResponsePayload(response)
-          .then(payload => payload.parsed);
-    },
-
-    /**
-     * @param {!Object} response
-     * @return {!Object}
-     */
-    _readResponsePayload(response) {
-      return response.text().then(text => {
-        let result;
-        try {
-          result = this._parsePrefixedJSON(text);
-        } catch (_) {
-          result = null;
-        }
-        return {parsed: result, raw: text};
-      });
-    },
-
-    /**
-     * @param {string} source
-     * @return {?}
-     */
-    _parsePrefixedJSON(source) {
-      return JSON.parse(source.substring(JSON_PREFIX.length));
-    },
-
-    /**
-     * @param {Defs.FetchJSONRequest} req
-     * @return {Defs.FetchJSONRequest}
-     */
-    _addAcceptJsonHeader(req) {
-      if (!req.fetchOptions) req.fetchOptions = {};
-      if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
-      if (!req.fetchOptions.headers.has('Accept')) {
-        req.fetchOptions.headers.append('Accept', 'application/json');
-      }
-      return req;
+      return this._restApiHelper.getResponseObject(response);
     },
 
     getConfig(noCache) {
@@ -442,7 +237,7 @@
         });
       }
 
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/config/server/info',
         reportUrlAsIs: true,
       });
@@ -492,7 +287,7 @@
       // supports it.
       const url = `/projects/${encodeURIComponent(repo)}/config`;
       this._cache.delete(url);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url,
         body: config,
@@ -506,7 +301,7 @@
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
       const encodeName = encodeURIComponent(repo);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'POST',
         url: `/projects/${encodeName}/gc`,
         body: '',
@@ -524,7 +319,7 @@
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
       const encodeName = encodeURIComponent(config.name);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: `/projects/${encodeName}`,
         body: config,
@@ -540,7 +335,7 @@
     createGroup(config, opt_errFn) {
       if (!config.name) { return ''; }
       const encodeName = encodeURIComponent(config.name);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: `/groups/${encodeName}`,
         body: config,
@@ -550,7 +345,7 @@
     },
 
     getGroupConfig(group, opt_errFn) {
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: `/groups/${encodeURIComponent(group)}/detail`,
         errFn: opt_errFn,
         anonymizedUrl: '/groups/*/detail',
@@ -568,7 +363,7 @@
       // supports it.
       const encodeName = encodeURIComponent(repo);
       const encodeRef = encodeURIComponent(ref);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'DELETE',
         url: `/projects/${encodeName}/branches/${encodeRef}`,
         body: '',
@@ -588,7 +383,7 @@
       // supports it.
       const encodeName = encodeURIComponent(repo);
       const encodeRef = encodeURIComponent(ref);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'DELETE',
         url: `/projects/${encodeName}/tags/${encodeRef}`,
         body: '',
@@ -609,7 +404,7 @@
       // supports it.
       const encodeName = encodeURIComponent(name);
       const encodeBranch = encodeURIComponent(branch);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: `/projects/${encodeName}/branches/${encodeBranch}`,
         body: revision,
@@ -630,7 +425,7 @@
       // supports it.
       const encodeName = encodeURIComponent(name);
       const encodeTag = encodeURIComponent(tag);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: `/projects/${encodeName}/tags/${encodeTag}`,
         body: revision,
@@ -655,7 +450,7 @@
 
     getGroupMembers(groupName, opt_errFn) {
       const encodeName = encodeURIComponent(groupName);
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: `/groups/${encodeName}/members/`,
         errFn: opt_errFn,
         anonymizedUrl: '/groups/*/members',
@@ -663,7 +458,7 @@
     },
 
     getIncludedGroup(groupName) {
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: `/groups/${encodeURIComponent(groupName)}/groups/`,
         anonymizedUrl: '/groups/*/groups',
       });
@@ -671,7 +466,7 @@
 
     saveGroupName(groupId, name) {
       const encodeId = encodeURIComponent(groupId);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: `/groups/${encodeId}/name`,
         body: {name},
@@ -681,7 +476,7 @@
 
     saveGroupOwner(groupId, ownerId) {
       const encodeId = encodeURIComponent(groupId);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: `/groups/${encodeId}/owner`,
         body: {owner: ownerId},
@@ -691,7 +486,7 @@
 
     saveGroupDescription(groupId, description) {
       const encodeId = encodeURIComponent(groupId);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: `/groups/${encodeId}/description`,
         body: {description},
@@ -701,7 +496,7 @@
 
     saveGroupOptions(groupId, options) {
       const encodeId = encodeURIComponent(groupId);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: `/groups/${encodeId}/options`,
         body: options,
@@ -720,7 +515,7 @@
     saveGroupMembers(groupName, groupMembers) {
       const encodeName = encodeURIComponent(groupName);
       const encodeMember = encodeURIComponent(groupMembers);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: `/groups/${encodeName}/members/${encodeMember}`,
         parseResponse: true,
@@ -737,7 +532,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/groups/*/groups/*',
       };
-      return this._send(req).then(response => {
+      return this._restApiHelper.send(req).then(response => {
         if (response.ok) {
           return this.getResponseObject(response);
         }
@@ -747,7 +542,7 @@
     deleteGroupMembers(groupName, groupMembers) {
       const encodeName = encodeURIComponent(groupName);
       const encodeMember = encodeURIComponent(groupMembers);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'DELETE',
         url: `/groups/${encodeName}/members/${encodeMember}`,
         anonymizedUrl: '/groups/*/members/*',
@@ -757,7 +552,7 @@
     deleteIncludedGroup(groupName, includedGroup) {
       const encodeName = encodeURIComponent(groupName);
       const encodeIncludedGroup = encodeURIComponent(includedGroup);
-      return this._send({
+      return this._restApiHelper.send({
         method: 'DELETE',
         url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
         anonymizedUrl: '/groups/*/groups/*',
@@ -844,7 +639,7 @@
         prefs.download_scheme = prefs.download_scheme.toLowerCase();
       }
 
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: '/accounts/self/preferences',
         body: prefs,
@@ -860,7 +655,7 @@
     saveDiffPreferences(prefs, opt_errFn) {
       // Invalidate the cache.
       this._cache.delete('/accounts/self/preferences.diff');
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: '/accounts/self/preferences.diff',
         body: prefs,
@@ -876,7 +671,7 @@
     saveEditPreferences(prefs, opt_errFn) {
       // Invalidate the cache.
       this._cache.delete('/accounts/self/preferences.edit');
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: '/accounts/self/preferences.edit',
         body: prefs,
@@ -910,14 +705,14 @@
     },
 
     getExternalIds() {
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/accounts/self/external.ids',
         reportUrlAsIs: true,
       });
     },
 
     deleteAccountIdentity(id) {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'POST',
         url: '/accounts/self/external.ids:delete',
         body: id,
@@ -931,7 +726,7 @@
      * @return {!Promise<!Object>}
      */
     getAccountDetails(userId) {
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: `/accounts/${encodeURIComponent(userId)}/detail`,
         anonymizedUrl: '/accounts/*/detail',
       });
@@ -949,7 +744,7 @@
      * @param {function(?Response, string=)=} opt_errFn
      */
     addAccountEmail(email, opt_errFn) {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: '/accounts/self/emails/' + encodeURIComponent(email),
         errFn: opt_errFn,
@@ -962,7 +757,7 @@
      * @param {function(?Response, string=)=} opt_errFn
      */
     deleteAccountEmail(email, opt_errFn) {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'DELETE',
         url: '/accounts/self/emails/' + encodeURIComponent(email),
         errFn: opt_errFn,
@@ -982,7 +777,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/accounts/self/emails/*/preferred',
       };
-      return this._send(req).then(() => {
+      return this._restApiHelper.send(req).then(() => {
         // If result of getAccountEmails is in cache, update it in the cache
         // so we don't have to invalidate it.
         const cachedEmails = this._cache.get('/accounts/self/emails');
@@ -1026,7 +821,7 @@
         parseResponse: true,
         reportUrlAsIs: true,
       };
-      return this._send(req)
+      return this._restApiHelper.send(req)
           .then(newName => this._updateCachedAccount({name: newName}));
     },
 
@@ -1043,7 +838,7 @@
         parseResponse: true,
         reportUrlAsIs: true,
       };
-      return this._send(req)
+      return this._restApiHelper.send(req)
           .then(newName => this._updateCachedAccount({username: newName}));
     },
 
@@ -1060,33 +855,33 @@
         parseResponse: true,
         reportUrlAsIs: true,
       };
-      return this._send(req)
+      return this._restApiHelper.send(req)
           .then(newStatus => this._updateCachedAccount({status: newStatus}));
     },
 
     getAccountStatus(userId) {
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: `/accounts/${encodeURIComponent(userId)}/status`,
         anonymizedUrl: '/accounts/*/status',
       });
     },
 
     getAccountGroups() {
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/accounts/self/groups',
         reportUrlAsIs: true,
       });
     },
 
     getAccountAgreements() {
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/accounts/self/agreements',
         reportUrlAsIs: true,
       });
     },
 
     saveAccountAgreement(name) {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: '/accounts/self/agreements',
         body: name,
@@ -1131,34 +926,7 @@
     },
 
     checkCredentials() {
-      if (this._credentialCheck.checking) {
-        return;
-      }
-      this._credentialCheck.checking = true;
-      let req = {url: '/accounts/self/detail', reportUrlAsIs: true};
-      req = this._addAcceptJsonHeader(req);
-      // Skip the REST response cache.
-      return this._fetchRawJSON(req).then(res => {
-        if (!res) { return; }
-        if (res.status === 403) {
-          this.fire('auth-error');
-          this._cache.delete('/accounts/self/detail');
-        } else if (res.ok) {
-          return this.getResponseObject(res);
-        }
-      }).then(res => {
-        this._credentialCheck.checking = false;
-        if (res) {
-          this._cache.set('/accounts/self/detail', res);
-        }
-        return res;
-      }).catch(err => {
-        this._credentialCheck.checking = false;
-        if (err && err.message === FAILED_TO_FETCH_ERROR) {
-          this.fire('auth-error');
-          this._cache.delete('/accounts/self/detail');
-        }
-      });
+      return this._restApiHelper.checkCredentials();
     },
 
     getDefaultPreferences() {
@@ -1204,7 +972,7 @@
      * @param {function(?Response, string=)=} opt_errFn
      */
     saveWatchedProjects(projects, opt_errFn) {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'POST',
         url: '/accounts/self/watched.projects',
         body: projects,
@@ -1219,7 +987,7 @@
      * @param {function(?Response, string=)=} opt_errFn
      */
     deleteWatchedProjects(projects, opt_errFn) {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'POST',
         url: '/accounts/self/watched.projects:delete',
         body: projects,
@@ -1228,45 +996,6 @@
       });
     },
 
-    /**
-     * @param {Defs.FetchJSONRequest} req
-     */
-    _fetchSharedCacheURL(req) {
-      if (this._sharedFetchPromises[req.url]) {
-        return this._sharedFetchPromises[req.url];
-      }
-      // TODO(andybons): Periodic cache invalidation.
-      if (this._cache.has(req.url)) {
-        return Promise.resolve(this._cache.get(req.url));
-      }
-      this._sharedFetchPromises[req.url] = this._fetchJSON(req)
-          .then(response => {
-            if (response !== undefined) {
-              this._cache.set(req.url, response);
-            }
-            this._sharedFetchPromises[req.url] = undefined;
-            return response;
-          }).catch(err => {
-            this._sharedFetchPromises[req.url] = undefined;
-            throw err;
-          });
-      return this._sharedFetchPromises[req.url];
-    },
-
-    /**
-     * @param {string} prefix
-     */
-    _invalidateSharedFetchPromisesPrefix(prefix) {
-      const newObject = {};
-      Object.entries(this._sharedFetchPromises).forEach(([key, value]) => {
-        if (!key.startsWith(prefix)) {
-          newObject[key] = value;
-        }
-      });
-      this._sharedFetchPromises = newObject;
-      this._cache.invalidatePrefix(prefix);
-    },
-
     _isNarrowScreen() {
       return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
     },
@@ -1308,7 +1037,7 @@
         params,
         reportUrlAsIs: true,
       };
-      return this._fetchJSON(req).then(response => {
+      return this._restApiHelper.fetchJSON(req).then(response => {
         // Response may be an array of changes OR an array of arrays of
         // changes.
         if (opt_query instanceof Array) {
@@ -1407,7 +1136,8 @@
      */
     _getChangeDetail(changeNum, optionsHex, opt_errFn, opt_cancelCondition) {
       return this.getChangeActionURL(changeNum, null, '/detail').then(url => {
-        const urlWithParams = this._urlWithParams(url, optionsHex);
+        const urlWithParams = this._restApiHelper
+            .urlWithParams(url, optionsHex);
         const params = {O: optionsHex};
         let req = {
           url,
@@ -1417,10 +1147,10 @@
           fetchOptions: this._etags.getOptions(urlWithParams),
           anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex,
         };
-        req = this._addAcceptJsonHeader(req);
-        return this._fetchRawJSON(req).then(response => {
+        req = this._restApiHelper.addAcceptJsonHeader(req);
+        return this._restApiHelper.fetchRawJSON(req).then(response => {
           if (response && response.status === 304) {
-            return Promise.resolve(this._parsePrefixedJSON(
+            return Promise.resolve(this._restApiHelper.parsePrefixedJSON(
                 this._etags.getCachedPayload(urlWithParams)));
           }
 
@@ -1434,7 +1164,7 @@
           }
 
           const payloadPromise = response ?
-              this._readResponsePayload(response) :
+              this._restApiHelper.readResponsePayload(response) :
               Promise.resolve(null);
 
           return payloadPromise.then(payload => {
@@ -1647,11 +1377,11 @@
     },
 
     invalidateGroupsCache() {
-      this._invalidateSharedFetchPromisesPrefix('/groups/?');
+      this._restApiHelper.invalidateFetchPromisesPrefix('/groups/?');
     },
 
     invalidateReposCache() {
-      this._invalidateSharedFetchPromisesPrefix('/projects/?');
+      this._restApiHelper.invalidateFetchPromisesPrefix('/projects/?');
     },
 
     /**
@@ -1689,7 +1419,7 @@
     setRepoHead(repo, ref) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: `/projects/${encodeURIComponent(repo)}/HEAD`,
         body: {ref},
@@ -1713,7 +1443,7 @@
       const url = `/projects/${repo}/branches?n=${count}&S=${offset}${filter}`;
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url,
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*/branches?*',
@@ -1737,7 +1467,7 @@
           encodedFilter;
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url,
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*/tags',
@@ -1756,7 +1486,7 @@
       const encodedFilter = this._computeFilter(filter);
       const n = pluginsPerPage + 1;
       const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url,
         errFn: opt_errFn,
         anonymizedUrl: '/plugins/?all',
@@ -1766,7 +1496,7 @@
     getRepoAccessRights(repoName, opt_errFn) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: `/projects/${encodeURIComponent(repoName)}/access`,
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*/access',
@@ -1776,7 +1506,7 @@
     setRepoAccessRights(repoName, repoInfo) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._send({
+      return this._restApiHelper.send({
         method: 'POST',
         url: `/projects/${encodeURIComponent(repoName)}/access`,
         body: repoInfo,
@@ -1785,7 +1515,7 @@
     },
 
     setRepoAccessRightsForReview(projectName, projectInfo) {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: `/projects/${encodeURIComponent(projectName)}/access:review`,
         body: projectInfo,
@@ -1802,7 +1532,7 @@
     getSuggestedGroups(inputVal, opt_n, opt_errFn) {
       const params = {s: inputVal};
       if (opt_n) { params.n = opt_n; }
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/groups/',
         errFn: opt_errFn,
         params,
@@ -1822,7 +1552,7 @@
         type: 'ALL',
       };
       if (opt_n) { params.n = opt_n; }
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/projects/',
         errFn: opt_errFn,
         params,
@@ -1841,7 +1571,7 @@
       }
       const params = {suggest: null, q: inputVal};
       if (opt_n) { params.n = opt_n; }
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/accounts/',
         errFn: opt_errFn,
         params,
@@ -1872,7 +1602,7 @@
                 throw Error('Unsupported HTTP method: ' + method);
             }
 
-            return this._send({method, url, body});
+            return this._restApiHelper.send({method, url, body});
           });
     },
 
@@ -1902,7 +1632,7 @@
         O: options,
         q: 'status:open is:mergeable conflicts:' + changeNum,
       };
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/changes/',
         params,
         anonymizedUrl: '/changes/conflicts:*',
@@ -1924,7 +1654,7 @@
         O: options,
         q: query,
       };
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/changes/',
         params,
         anonymizedUrl: '/changes/change:*',
@@ -1947,7 +1677,7 @@
         O: options,
         q: query,
       };
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/changes/',
         params,
         anonymizedUrl: '/changes/topic:*',
@@ -1993,7 +1723,7 @@
         this.getChangeActionURL(changeNum, patchNum, '/review'),
       ];
       return Promise.all(promises).then(([, url]) => {
-        return this._send({
+        return this._restApiHelper.send({
           method: 'POST',
           url,
           body: review,
@@ -2027,7 +1757,7 @@
      */
     createChange(project, branch, subject, opt_topic, opt_isPrivate,
         opt_workInProgress, opt_baseChange, opt_baseCommit) {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'POST',
         url: '/changes/',
         body: {
@@ -2202,7 +1932,7 @@
       return this.getFromProjectLookup(changeNum).then(project => {
         const url = '/accounts/self/starred.changes/' +
             (project ? encodeURIComponent(project) + '~' : '') + changeNum;
-        return this._send({
+        return this._restApiHelper.send({
           method: starred ? 'PUT' : 'DELETE',
           url,
           anonymizedUrl: '/accounts/self/starred.changes/*',
@@ -2219,59 +1949,7 @@
     },
 
     /**
-     * Send an XHR.
-     * @param {Defs.SendRequest} req
-     * @return {Promise}
-     */
-    _send(req) {
-      const options = {method: req.method};
-      if (req.body) {
-        options.headers = new Headers();
-        options.headers.set(
-            'Content-Type', req.contentType || 'application/json');
-        options.body = typeof req.body === 'string' ?
-            req.body : JSON.stringify(req.body);
-      }
-      if (req.headers) {
-        if (!options.headers) { options.headers = new Headers(); }
-        for (const header in req.headers) {
-          if (!req.headers.hasOwnProperty(header)) { continue; }
-          options.headers.set(header, req.headers[header]);
-        }
-      }
-      const url = req.url.startsWith('http') ?
-          req.url : this.getBaseUrl() + req.url;
-      const fetchReq = {
-        url,
-        fetchOptions: options,
-        anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
-      };
-      const xhr = this._fetch(fetchReq).then(response => {
-        if (!response.ok) {
-          if (req.errFn) {
-            return req.errFn.call(undefined, response);
-          }
-          this.fire('server-error', {request: fetchReq, response});
-        }
-        return response;
-      }).catch(err => {
-        this.fire('network-error', {error: err});
-        if (req.errFn) {
-          return req.errFn.call(undefined, null, err);
-        } else {
-          throw err;
-        }
-      });
-
-      if (req.parseResponse) {
-        return xhr.then(res => this.getResponseObject(res));
-      }
-
-      return xhr;
-    },
-
-    /**
-     * Public version of the _send method preserved for plugins.
+     * Public version of the _restApiHelper.send method preserved for plugins.
      * @param {string} method
      * @param {string} url
      * @param {?string|number|Object=} opt_body passed as null sometimes
@@ -2284,7 +1962,7 @@
      */
     send(method, url, opt_body, opt_errFn, opt_contentType,
         opt_headers) {
-      return this._send({
+      return this._restApiHelper.send({
         method,
         url,
         body: opt_body,
@@ -2541,7 +2219,7 @@
     },
 
     getCommitInfo(project, commit) {
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/projects/' + encodeURIComponent(project) +
             '/commits/' + encodeURIComponent(commit),
         anonymizedUrl: '/projects/*/comments/*',
@@ -2549,7 +2227,7 @@
     },
 
     _fetchB64File(url) {
-      return this._fetch({url: this.getBaseUrl() + url})
+      return this._restApiHelper.fetch({url: this.getBaseUrl() + url})
           .then(response => {
             if (!response.ok) {
               return Promise.reject(new Error(response.statusText));
@@ -2673,7 +2351,7 @@
     },
 
     deleteAccountHttpPassword() {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'DELETE',
         url: '/accounts/self/password.http',
         reportUrlAsIs: true,
@@ -2686,7 +2364,7 @@
      * parameter.
      */
     generateAccountHttpPassword() {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'PUT',
         url: '/accounts/self/password.http',
         body: {generate: true},
@@ -2710,7 +2388,7 @@
         contentType: 'plain/text',
         reportUrlAsIs: true,
       };
-      return this._send(req)
+      return this._restApiHelper.send(req)
           .then(response => {
             if (response.status < 200 && response.status >= 300) {
               return Promise.reject(new Error('error'));
@@ -2724,7 +2402,7 @@
     },
 
     deleteAccountSSHKey(id) {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'DELETE',
         url: '/accounts/self/sshkeys/' + id,
         anonymizedUrl: '/accounts/self/sshkeys/*',
@@ -2732,7 +2410,7 @@
     },
 
     getAccountGPGKeys() {
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/accounts/self/gpgkeys',
         reportUrlAsIs: true,
       });
@@ -2745,7 +2423,7 @@
         body: key,
         reportUrlAsIs: true,
       };
-      return this._send(req)
+      return this._restApiHelper.send(req)
           .then(response => {
             if (response.status < 200 && response.status >= 300) {
               return Promise.reject(new Error('error'));
@@ -2759,7 +2437,7 @@
     },
 
     deleteAccountGPGKey(id) {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'DELETE',
         url: '/accounts/self/gpgkeys/' + id,
         anonymizedUrl: '/accounts/self/gpgkeys/*',
@@ -2792,7 +2470,7 @@
         body: {token},
         reportUrlAsIs: true,
       };
-      return this._send(req).then(response => {
+      return this._restApiHelper.send(req).then(response => {
         if (response.status === 204) {
           return 'Email confirmed successfully.';
         }
@@ -2801,7 +2479,7 @@
     },
 
     getCapabilities(opt_errFn) {
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: '/config/server/capabilities',
         errFn: opt_errFn,
         reportUrlAsIs: true,
@@ -2907,7 +2585,7 @@
      */
     getChange(changeNum, opt_errFn) {
       // Cannot use _changeBaseURL, as this function is used by _projectLookup.
-      return this._fetchJSON({
+      return this._restApiHelper.fetchJSON({
         url: `/changes/?q=change:${changeNum}`,
         errFn: opt_errFn,
         anonymizedUrl: '/changes/?q=change:*',
@@ -2967,7 +2645,7 @@
           req.endpoint : req.anonymizedEndpoint;
 
       return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
-        return this._send({
+        return this._restApiHelper.send({
           method: req.method,
           url: url + req.endpoint,
           body: req.body,
@@ -2992,7 +2670,7 @@
       const anonymizedBaseUrl = req.patchNum ?
           ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
       return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
-        return this._fetchJSON({
+        return this._restApiHelper.fetchJSON({
           url: url + req.endpoint,
           errFn: req.errFn,
           params: req.params,
@@ -3119,7 +2797,7 @@
     },
 
     deleteDraftComments(query) {
-      return this._send({
+      return this._restApiHelper.send({
         method: 'POST',
         url: '/accounts/self/drafts:delete',
         body: {query},
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index 9d0d83a..ea71522 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -63,116 +63,8 @@
       sandbox.restore();
     });
 
-    suite('fetchJSON()', () => {
-      test('Sets header to accept application/json', () => {
-        const authFetchStub = sandbox.stub(element._auth, 'fetch')
-            .returns(Promise.resolve());
-        element._fetchJSON({url: '/dummy/url'});
-        assert.isTrue(authFetchStub.called);
-        assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
-            'application/json');
-      });
-
-      test('Use header option accept when provided', () => {
-        const authFetchStub = sandbox.stub(element._auth, 'fetch')
-            .returns(Promise.resolve());
-        const headers = new Headers();
-        headers.append('Accept', '*/*');
-        const fetchOptions = {headers};
-        element._fetchJSON({url: '/dummy/url', fetchOptions});
-        assert.isTrue(authFetchStub.called);
-        assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
-            '*/*');
-      });
-    });
-
-    test('JSON prefix is properly removed', done => {
-      element._fetchJSON({url: '/dummy/url'}).then(obj => {
-        assert.deepEqual(obj, {hello: 'bonjour'});
-        done();
-      });
-    });
-
-    test('cached results', done => {
-      let n = 0;
-      sandbox.stub(element, '_fetchJSON', () => {
-        return Promise.resolve(++n);
-      });
-      const promises = [];
-      promises.push(element._fetchSharedCacheURL('/foo'));
-      promises.push(element._fetchSharedCacheURL('/foo'));
-      promises.push(element._fetchSharedCacheURL('/foo'));
-
-      Promise.all(promises).then(results => {
-        assert.deepEqual(results, [1, 1, 1]);
-        element._fetchSharedCacheURL('/foo').then(foo => {
-          assert.equal(foo, 1);
-          done();
-        });
-      });
-    });
-
-    test('cached promise', done => {
-      const promise = Promise.reject(new Error('foo'));
-      element._cache.set('/foo', promise);
-      element._fetchSharedCacheURL({url: '/foo'}).catch(p => {
-        assert.equal(p.message, 'foo');
-        done();
-      });
-    });
-
-    test('cache invalidation', () => {
-      element._cache.set('/foo/bar', 1);
-      element._cache.set('/bar', 2);
-      element._sharedFetchPromises['/foo/bar'] = 3;
-      element._sharedFetchPromises['/bar'] = 4;
-      element._invalidateSharedFetchPromisesPrefix('/foo/');
-      assert.isFalse(element._cache.has('/foo/bar'));
-      assert.isTrue(element._cache.has('/bar'));
-      assert.isUndefined(element._sharedFetchPromises['/foo/bar']);
-      assert.strictEqual(4, element._sharedFetchPromises['/bar']);
-    });
-
-    test('params are properly encoded', () => {
-      let url = element._urlWithParams('/path/', {
-        sp: 'hola',
-        gr: 'guten tag',
-        noval: null,
-      });
-      assert.equal(url,
-          window.CANONICAL_PATH + '/path/?sp=hola&gr=guten%20tag&noval');
-
-      url = element._urlWithParams('/path/', {
-        sp: 'hola',
-        en: ['hey', 'hi'],
-      });
-      assert.equal(url, window.CANONICAL_PATH + '/path/?sp=hola&en=hey&en=hi');
-
-      // Order must be maintained with array params.
-      url = element._urlWithParams('/path/', {
-        l: ['c', 'b', 'a'],
-      });
-      assert.equal(url, window.CANONICAL_PATH + '/path/?l=c&l=b&l=a');
-    });
-
-    test('request callbacks can be canceled', done => {
-      let cancelCalled = false;
-      window.fetch.returns(Promise.resolve({
-        body: {
-          cancel() { cancelCalled = true; },
-        },
-      }));
-      const cancelCondition = () => { return true; };
-      element._fetchJSON({url: '/dummy/url', cancelCondition}).then(
-          obj => {
-            assert.isUndefined(obj);
-            assert.isTrue(cancelCalled);
-            done();
-          });
-    });
-
     test('parent diff comments are properly grouped', done => {
-      sandbox.stub(element, '_fetchJSON', () => {
+      sandbox.stub(element._restApiHelper, 'fetchJSON', () => {
         return Promise.resolve({
           '/COMMIT_MSG': [],
           'sieve.go': [
@@ -315,7 +207,7 @@
     test('differing patch diff comments are properly grouped', done => {
       sandbox.stub(element, 'getFromProjectLookup')
           .returns(Promise.resolve('test'));
-      sandbox.stub(element, '_fetchJSON', request => {
+      sandbox.stub(element._restApiHelper, 'fetchJSON', request => {
         const url = request.url;
         if (url === '/changes/test~42/revisions/1') {
           return Promise.resolve({
@@ -432,7 +324,7 @@
     suite('rebase action', () => {
       let resolve_fetchJSON;
       setup(() => {
-        sandbox.stub(element, '_fetchJSON').returns(
+        sandbox.stub(element._restApiHelper, 'fetchJSON').returns(
             new Promise(resolve => {
               resolve_fetchJSON = resolve;
             }));
@@ -467,7 +359,7 @@
         element.addEventListener('server-error', resolve);
       });
 
-      element._fetchJSON({}).then(response => {
+      element._restApiHelper.fetchJSON({}).then(response => {
         assert.isUndefined(response);
         assert.isTrue(getResponseObjectStub.notCalled);
         serverErrorEventPromise.then(() => done());
@@ -483,12 +375,12 @@
           Promise.reject(new Error('Failed to fetch')));
       window.fetch.onSecondCall().returns(Promise.resolve(fakeAuthResponse));
       // Emulate logged in.
-      element._cache.set('/accounts/self/detail', {});
+      element._restApiHelper._cache.set('/accounts/self/detail', {});
       const serverErrorStub = sandbox.stub();
       element.addEventListener('server-error', serverErrorStub);
       const authErrorStub = sandbox.stub();
       element.addEventListener('auth-error', authErrorStub);
-      element._fetchJSON({url: '/bar'}).finally(r => {
+      element._restApiHelper.fetchJSON({url: '/bar'}).finally(r => {
         flush(() => {
           assert.isTrue(authErrorStub.called);
           assert.isFalse(serverErrorStub.called);
@@ -507,7 +399,7 @@
       element.addEventListener('server-error', serverErrorStub);
       const authErrorStub = sandbox.stub();
       element.addEventListener('auth-error', authErrorStub);
-      element._fetchJSON({url: '/bar'}).finally(r => {
+      element._restApiHelper.fetchJSON({url: '/bar'}).finally(r => {
         flush(() => {
           assert.isTrue(authErrorStub.called);
           assert.isFalse(serverErrorStub.called);
@@ -558,7 +450,8 @@
     test('checkCredentials promise rejection', () => {
       window.fetch.restore();
       element._cache.set('/accounts/self/detail', true);
-      sandbox.spy(element, 'checkCredentials');
+      const checkCredentialsSpy =
+          sandbox.spy(element._restApiHelper, 'checkCredentials');
       sandbox.stub(window, 'fetch', url => {
         return Promise.reject(new Error('Failed to fetch'));
       });
@@ -570,7 +463,7 @@
             // The second fetch call also fails, which leads to a second
             // invocation of checkCredentials, which should immediately
             // return instead of making further fetch calls.
-            assert.isTrue(element.checkCredentials.calledTwice);
+            assert.isTrue(checkCredentialsSpy .calledTwice);
             assert.isTrue(window.fetch.calledTwice);
           });
     });
@@ -585,7 +478,7 @@
     });
 
     test('legacy n,z key in change url is replaced', () => {
-      const stub = sandbox.stub(element, '_fetchJSON')
+      const stub = sandbox.stub(element._restApiHelper, 'fetchJSON')
           .returns(Promise.resolve([]));
       element.getChanges(1, null, 'n,z');
       assert.equal(stub.lastCall.args[0].params.S, 0);
@@ -593,38 +486,38 @@
 
     test('saveDiffPreferences invalidates cache line', () => {
       const cacheKey = '/accounts/self/preferences.diff';
-      sandbox.stub(element, '_send');
+      const sendStub = sandbox.stub(element._restApiHelper, 'send');
       element._cache.set(cacheKey, {tab_size: 4});
       element.saveDiffPreferences({tab_size: 8});
-      assert.isTrue(element._send.called);
-      assert.isFalse(element._cache.has(cacheKey));
+      assert.isTrue(sendStub.called);
+      assert.isFalse(element._restApiHelper._cache.has(cacheKey));
     });
 
     test('getAccount when resp is null does not add anything to the cache',
         done => {
           const cacheKey = '/accounts/self/detail';
-          const stub = sandbox.stub(element, '_fetchSharedCacheURL',
+          const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
               () => Promise.resolve());
 
           element.getAccount().then(() => {
-            assert.isTrue(element._fetchSharedCacheURL.called);
-            assert.isFalse(element._cache.has(cacheKey));
+            assert.isTrue(stub.called);
+            assert.isFalse(element._restApiHelper._cache.has(cacheKey));
             done();
           });
 
-          element._cache.set(cacheKey, 'fake cache');
+          element._restApiHelper._cache.set(cacheKey, 'fake cache');
           stub.lastCall.args[0].errFn();
         });
 
     test('getAccount does not add to the cache when resp.status is 403',
         done => {
           const cacheKey = '/accounts/self/detail';
-          const stub = sandbox.stub(element, '_fetchSharedCacheURL',
+          const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
               () => Promise.resolve());
 
           element.getAccount().then(() => {
-            assert.isTrue(element._fetchSharedCacheURL.called);
-            assert.isFalse(element._cache.has(cacheKey));
+            assert.isTrue(stub.called);
+            assert.isFalse(element._restApiHelper._cache.has(cacheKey));
             done();
           });
           element._cache.set(cacheKey, 'fake cache');
@@ -633,15 +526,15 @@
 
     test('getAccount when resp is successful', done => {
       const cacheKey = '/accounts/self/detail';
-      const stub = sandbox.stub(element, '_fetchSharedCacheURL',
+      const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
           () => Promise.resolve());
 
       element.getAccount().then(response => {
-        assert.isTrue(element._fetchSharedCacheURL.called);
-        assert.equal(element._cache.get(cacheKey), 'fake cache');
+        assert.isTrue(stub.called);
+        assert.equal(element._restApiHelper._cache.get(cacheKey), 'fake cache');
         done();
       });
-      element._cache.set(cacheKey, 'fake cache');
+      element._restApiHelper._cache.set(cacheKey, 'fake cache');
 
       stub.lastCall.args[0].errFn({});
     });
@@ -653,7 +546,7 @@
       sandbox.stub(element, '_isNarrowScreen', () => {
         return smallScreen;
       });
-      sandbox.stub(element, '_fetchSharedCacheURL', () => {
+      sandbox.stub(element._restApiHelper, 'fetchCacheURL', () => {
         return Promise.resolve(testJSON);
       });
     };
@@ -718,10 +611,10 @@
         });
 
     test('savPreferences normalizes download scheme', () => {
-      sandbox.stub(element, '_send');
+      const sendStub = sandbox.stub(element._restApiHelper, 'send');
       element.savePreferences({download_scheme: 'HTTP'});
-      assert.isTrue(element._send.called);
-      assert.equal(element._send.lastCall.args[0].body.download_scheme, 'http');
+      assert.isTrue(sendStub.called);
+      assert.equal(sendStub.lastCall.args[0].body.download_scheme, 'http');
     });
 
     test('getDiffPreferences returns correct defaults', done => {
@@ -747,10 +640,10 @@
     });
 
     test('saveDiffPreferences set show_tabs to false', () => {
-      sandbox.stub(element, '_send');
+      const sendStub = sandbox.stub(element._restApiHelper, 'send');
       element.saveDiffPreferences({show_tabs: false});
-      assert.isTrue(element._send.called);
-      assert.equal(element._send.lastCall.args[0].body.show_tabs, false);
+      assert.isTrue(sendStub.called);
+      assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
     });
 
     test('getEditPreferences returns correct defaults', done => {
@@ -780,34 +673,36 @@
     });
 
     test('saveEditPreferences set show_tabs to false', () => {
-      sandbox.stub(element, '_send');
+      const sendStub = sandbox.stub(element._restApiHelper, 'send');
       element.saveEditPreferences({show_tabs: false});
-      assert.isTrue(element._send.called);
-      assert.equal(element._send.lastCall.args[0].body.show_tabs, false);
+      assert.isTrue(sendStub.called);
+      assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
     });
 
     test('confirmEmail', () => {
-      sandbox.spy(element, '_send');
+      const sendStub = sandbox.spy(element._restApiHelper, 'send');
       element.confirmEmail('foo');
-      assert.isTrue(element._send.calledOnce);
-      assert.equal(element._send.lastCall.args[0].method, 'PUT');
-      assert.equal(element._send.lastCall.args[0].url,
+      assert.isTrue(sendStub.calledOnce);
+      assert.equal(sendStub.lastCall.args[0].method, 'PUT');
+      assert.equal(sendStub.lastCall.args[0].url,
           '/config/server/email.confirm');
-      assert.deepEqual(element._send.lastCall.args[0].body, {token: 'foo'});
+      assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
     });
 
     test('setAccountStatus', () => {
-      sandbox.stub(element, '_send').returns(Promise.resolve('OOO'));
+      const sendStub = sandbox.stub(element._restApiHelper, 'send')
+        .returns(Promise.resolve('OOO'));
       element._cache.set('/accounts/self/detail', {});
       return element.setAccountStatus('OOO').then(() => {
-        assert.isTrue(element._send.calledOnce);
-        assert.equal(element._send.lastCall.args[0].method, 'PUT');
-        assert.equal(element._send.lastCall.args[0].url,
+        assert.isTrue(sendStub.calledOnce);
+        assert.equal(sendStub.lastCall.args[0].method, 'PUT');
+        assert.equal(sendStub.lastCall.args[0].url,
             '/accounts/self/status');
-        assert.deepEqual(element._send.lastCall.args[0].body,
+        assert.deepEqual(sendStub.lastCall.args[0].body,
             {status: 'OOO'});
-        assert.deepEqual(element._cache.get('/accounts/self/detail'),
-            {status: 'OOO'});
+        assert.deepEqual(element._restApiHelper
+            ._cache.get('/accounts/self/detail'),
+           {status: 'OOO'});
       });
     });
 
@@ -896,18 +791,20 @@
       const change_num = '1';
       const file_name = 'index.php';
       const file_contents = '<?php';
-      sandbox.stub(element, '_send').returns(
+      sandbox.stub(element._restApiHelper, 'send').returns(
           Promise.resolve([change_num, file_name, file_contents]));
       sandbox.stub(element, 'getResponseObject')
           .returns(Promise.resolve([change_num, file_name, file_contents]));
       element._cache.set('/changes/' + change_num + '/edit/' + file_name, {});
       return element.saveChangeEdit(change_num, file_name, file_contents)
           .then(() => {
-            assert.isTrue(element._send.calledOnce);
-            assert.equal(element._send.lastCall.args[0].method, 'PUT');
-            assert.equal(element._send.lastCall.args[0].url,
+            assert.isTrue(element._restApiHelper.send.calledOnce);
+            assert.equal(element._restApiHelper.send.lastCall.args[0].method,
+                'PUT');
+            assert.equal(element._restApiHelper.send.lastCall.args[0].url,
                 '/changes/test~1/edit/' + file_name);
-            assert.equal(element._send.lastCall.args[0].body, file_contents);
+            assert.equal(element._restApiHelper.send.lastCall.args[0].body,
+                file_contents);
           });
     });
 
@@ -915,17 +812,18 @@
       element._projectLookup = {1: 'test'};
       const change_num = '1';
       const message = 'this is a commit message';
-      sandbox.stub(element, '_send').returns(
+      sandbox.stub(element._restApiHelper, 'send').returns(
           Promise.resolve([change_num, message]));
       sandbox.stub(element, 'getResponseObject')
           .returns(Promise.resolve([change_num, message]));
       element._cache.set('/changes/' + change_num + '/message', {});
       return element.putChangeCommitMessage(change_num, message).then(() => {
-        assert.isTrue(element._send.calledOnce);
-        assert.equal(element._send.lastCall.args[0].method, 'PUT');
-        assert.equal(element._send.lastCall.args[0].url,
+        assert.isTrue(element._restApiHelper.send.calledOnce);
+        assert.equal(element._restApiHelper.send.lastCall.args[0].method, 'PUT');
+        assert.equal(element._restApiHelper.send.lastCall.args[0].url,
             '/changes/test~1/message');
-        assert.deepEqual(element._send.lastCall.args[0].body, {message});
+        assert.deepEqual(element._restApiHelper.send.lastCall.args[0].body,
+            {message});
       });
     });
 
@@ -981,7 +879,7 @@
     });
 
     test('createRepo encodes name', () => {
-      const sendStub = sandbox.stub(element, '_send')
+      const sendStub = sandbox.stub(element._restApiHelper, 'send')
           .returns(Promise.resolve());
       return element.createRepo({name: 'x/y'}).then(() => {
         assert.isTrue(sendStub.calledOnce);
@@ -1027,64 +925,65 @@
 
     suite('getRepos', () => {
       const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
-
+      let fetchCacheURLStub;
       setup(() => {
-        sandbox.stub(element, '_fetchSharedCacheURL');
+        fetchCacheURLStub =
+            sandbox.stub(element._restApiHelper, 'fetchCacheURL');
       });
 
       test('normal use', () => {
         element.getRepos('test', 25);
-        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
             '/projects/?n=26&S=0&query=test');
 
         element.getRepos(null, 25);
-        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
             `/projects/?n=26&S=0&query=${defaultQuery}`);
 
         element.getRepos('test', 25, 25);
-        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
             '/projects/?n=26&S=25&query=test');
       });
 
       test('with blank', () => {
         element.getRepos('test/test', 25);
-        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
             '/projects/?n=26&S=0&query=inname%3Atest%20AND%20inname%3Atest');
       });
 
       test('with hyphen', () => {
         element.getRepos('foo-bar', 25);
-        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
             '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
       });
 
       test('with leading hyphen', () => {
         element.getRepos('-bar', 25);
-        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
             '/projects/?n=26&S=0&query=inname%3Abar');
       });
 
       test('with trailing hyphen', () => {
         element.getRepos('foo-bar-', 25);
-        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
             '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
       });
 
       test('with underscore', () => {
         element.getRepos('foo_bar', 25);
-        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
             '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
       });
 
       test('with underscore', () => {
         element.getRepos('foo_bar', 25);
-        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
             '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
       });
 
       test('hyphen only', () => {
         element.getRepos('-', 25);
-        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
             `/projects/?n=26&S=0&query=${defaultQuery}`);
       });
     });
@@ -1113,43 +1012,45 @@
     });
 
     suite('getGroups', () => {
+      let fetchCacheURLStub;
       setup(() => {
-        sandbox.stub(element, '_fetchSharedCacheURL');
+        fetchCacheURLStub =
+            sandbox.stub(element._restApiHelper, 'fetchCacheURL');
       });
 
       test('normal use', () => {
         element.getGroups('test', 25);
-        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
             '/groups/?n=26&S=0&m=test');
 
         element.getGroups(null, 25);
-        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
             '/groups/?n=26&S=0');
 
         element.getGroups('test', 25, 25);
-        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
             '/groups/?n=26&S=25&m=test');
       });
 
       test('regex', () => {
         element.getGroups('^test.*', 25);
-        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
             '/groups/?n=26&S=0&r=%5Etest.*');
 
         element.getGroups('^test.*', 25, 25);
-        assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+        assert.equal(fetchCacheURLStub.lastCall.args[0].url,
             '/groups/?n=26&S=25&r=%5Etest.*');
       });
     });
 
     test('gerrit auth is used', () => {
       sandbox.stub(Gerrit.Auth, 'fetch').returns(Promise.resolve());
-      element._fetchJSON({url: 'foo'});
+      element._restApiHelper.fetchJSON({url: 'foo'});
       assert(Gerrit.Auth.fetch.called);
     });
 
     test('getSuggestedAccounts does not return _fetchJSON', () => {
-      const _fetchJSONSpy = sandbox.spy(element, '_fetchJSON');
+      const _fetchJSONSpy = sandbox.spy(element._restApiHelper, 'fetchJSON');
       return element.getSuggestedAccounts().then(accts => {
         assert.isFalse(_fetchJSONSpy.called);
         assert.equal(accts.length, 0);
@@ -1157,7 +1058,7 @@
     });
 
     test('_fetchJSON gets called by getSuggestedAccounts', () => {
-      const _fetchJSONStub = sandbox.stub(element, '_fetchJSON',
+      const _fetchJSONStub = sandbox.stub(element._restApiHelper, 'fetchJSON',
           () => Promise.resolve());
       return element.getSuggestedAccounts('own').then(() => {
         assert.deepEqual(_fetchJSONStub.lastCall.args[0].params, {
@@ -1229,7 +1130,7 @@
         const errFn = sinon.stub();
         sandbox.stub(element, 'getChangeActionURL')
             .returns(Promise.resolve(''));
-        sandbox.stub(element, '_fetchRawJSON')
+        sandbox.stub(element._restApiHelper, 'fetchRawJSON')
             .returns(Promise.resolve({ok: false, status: 500}));
         return element._getChangeDetail(123, '516714', errFn).then(() => {
           assert.isTrue(errFn.called);
@@ -1249,14 +1150,15 @@
       test('_getChangeDetail populates _projectLookup', () => {
         sandbox.stub(element, 'getChangeActionURL')
             .returns(Promise.resolve(''));
-        sandbox.stub(element, '_fetchRawJSON')
+        sandbox.stub(element._restApiHelper, 'fetchRawJSON')
             .returns(Promise.resolve({ok: true}));
 
         const mockResponse = {_number: 1, project: 'test'};
-        sandbox.stub(element, '_readResponsePayload').returns(Promise.resolve({
-          parsed: mockResponse,
-          raw: JSON.stringify(mockResponse),
-        }));
+        sandbox.stub(element._restApiHelper, 'readResponsePayload')
+            .returns(Promise.resolve({
+              parsed: mockResponse,
+              raw: JSON.stringify(mockResponse),
+            }));
         return element._getChangeDetail(1, '516714').then(() => {
           assert.equal(Object.keys(element._projectLookup).length, 1);
           assert.equal(element._projectLookup[1], 'test');
@@ -1274,7 +1176,8 @@
           const mockResponse = {foo: 'bar', baz: 42};
           mockResponseSerial = element.JSON_PREFIX +
               JSON.stringify(mockResponse);
-          sandbox.stub(element, '_urlWithParams').returns(requestUrl);
+          sandbox.stub(element._restApiHelper, 'urlWithParams')
+              .returns(requestUrl);
           sandbox.stub(element, 'getChangeActionURL')
               .returns(Promise.resolve(requestUrl));
           collectSpy = sandbox.spy(element._etags, 'collect');
@@ -1282,11 +1185,12 @@
         });
 
         test('contributes to cache', () => {
-          sandbox.stub(element, '_fetchRawJSON').returns(Promise.resolve({
-            text: () => Promise.resolve(mockResponseSerial),
-            status: 200,
-            ok: true,
-          }));
+          sandbox.stub(element._restApiHelper, 'fetchRawJSON')
+              .returns(Promise.resolve({
+                text: () => Promise.resolve(mockResponseSerial),
+                status: 200,
+                ok: true,
+              }));
 
           return element._getChangeDetail(123, '516714').then(detail => {
             assert.isFalse(getPayloadSpy.called);
@@ -1297,11 +1201,12 @@
         });
 
         test('uses cache on HTTP 304', () => {
-          sandbox.stub(element, '_fetchRawJSON').returns(Promise.resolve({
-            text: () => Promise.resolve(mockResponseSerial),
-            status: 304,
-            ok: true,
-          }));
+          sandbox.stub(element._restApiHelper, 'fetchRawJSON')
+              .returns(Promise.resolve({
+                text: () => Promise.resolve(mockResponseSerial),
+                status: 304,
+                ok: true,
+              }));
 
           return element._getChangeDetail(123, {}).then(detail => {
             assert.isFalse(collectSpy.called);
@@ -1346,7 +1251,7 @@
 
     suite('getChanges populates _projectLookup', () => {
       test('multiple queries', () => {
-        sandbox.stub(element, '_fetchJSON')
+        sandbox.stub(element._restApiHelper, 'fetchJSON')
             .returns(Promise.resolve([
               [
                 {_number: 1, project: 'test'},
@@ -1366,7 +1271,7 @@
       });
 
       test('no query', () => {
-        sandbox.stub(element, '_fetchJSON')
+        sandbox.stub(element._restApiHelper, 'fetchJSON')
             .returns(Promise.resolve([
               {_number: 1, project: 'test'},
               {_number: 2, project: 'test'},
@@ -1386,7 +1291,7 @@
 
     test('_getChangeURLAndFetch', () => {
       element._projectLookup = {1: 'test'};
-      const fetchStub = sandbox.stub(element, '_fetchJSON')
+      const fetchStub = sandbox.stub(element._restApiHelper, 'fetchJSON')
           .returns(Promise.resolve());
       const req = {changeNum: 1, endpoint: '/test', patchNum: 1};
       return element._getChangeURLAndFetch(req).then(() => {
@@ -1397,7 +1302,7 @@
 
     test('_getChangeURLAndSend', () => {
       element._projectLookup = {1: 'test'};
-      const sendStub = sandbox.stub(element, '_send')
+      const sendStub = sandbox.stub(element._restApiHelper, 'send')
           .returns(Promise.resolve());
 
       const req = {
@@ -1419,16 +1324,17 @@
         const mockObject = {foo: 'bar', baz: 'foo'};
         const serial = element.JSON_PREFIX + JSON.stringify(mockObject);
         const mockResponse = {text: () => Promise.resolve(serial)};
-        return element._readResponsePayload(mockResponse).then(payload => {
-          assert.deepEqual(payload.parsed, mockObject);
-          assert.equal(payload.raw, serial);
-        });
+        return element._restApiHelper.readResponsePayload(mockResponse)
+            .then(payload => {
+              assert.deepEqual(payload.parsed, mockObject);
+              assert.equal(payload.raw, serial);
+            });
       });
 
       test('_parsePrefixedJSON', () => {
         const obj = {x: 3, y: {z: 4}, w: 23};
         const serial = element.JSON_PREFIX + JSON.stringify(obj);
-        const result = element._parsePrefixedJSON(serial);
+        const result = element._restApiHelper.parsePrefixedJSON(serial);
         assert.deepEqual(result, obj);
       });
     });
@@ -1450,7 +1356,7 @@
     });
 
     test('generateAccountHttpPassword', () => {
-      const sendSpy = sandbox.spy(element, '_send');
+      const sendSpy = sandbox.spy(element._restApiHelper, 'send');
       return element.generateAccountHttpPassword().then(() => {
         assert.isTrue(sendSpy.calledOnce);
         assert.deepEqual(sendSpy.lastCall.args[0].body, {generate: true});
@@ -1535,11 +1441,12 @@
     });
 
     test('getDashboard', () => {
-      const fetchStub = sandbox.stub(element, '_fetchSharedCacheURL');
+      const fetchCacheURLStub = sandbox.stub(element._restApiHelper,
+          'fetchCacheURL');
       element.getDashboard('gerrit/project', 'default:main');
-      assert.isTrue(fetchStub.calledOnce);
+      assert.isTrue(fetchCacheURLStub.calledOnce);
       assert.equal(
-          fetchStub.lastCall.args[0].url,
+          fetchCacheURLStub.lastCall.args[0].url,
           '/projects/gerrit%2Fproject/dashboards/default%3Amain');
     });
 
@@ -1607,7 +1514,7 @@
     });
 
     test('_fetch forwards request and logs', () => {
-      const logStub = sandbox.stub(element, '_logCall');
+      const logStub = sandbox.stub(element._restApiHelper, '_logCall');
       const response = {status: 404, text: sinon.stub()};
       const url = 'my url';
       const fetchOptions = {method: 'DELETE'};
@@ -1615,7 +1522,7 @@
       const startTime = 123;
       sandbox.stub(Date, 'now').returns(startTime);
       const req = {url, fetchOptions};
-      return element._fetch(req).then(() => {
+      return element._restApiHelper.fetch(req).then(() => {
         assert.isTrue(logStub.calledOnce);
         assert.isTrue(logStub.calledWith(req, startTime, response.status));
         assert.isFalse(response.text.called);
@@ -1627,10 +1534,11 @@
       const handler = sinon.stub();
       element.addEventListener('rpc-log', handler);
 
-      element._logCall({url: 'url'}, 100, 200);
+      element._restApiHelper._logCall({url: 'url'}, 100, 200);
       assert.isFalse(handler.called);
 
-      element._logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
+      element._restApiHelper
+          ._logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
       flushAsynchronousOperations();
       assert.isTrue(handler.calledOnce);
     });
@@ -1639,7 +1547,7 @@
       sandbox.stub(element, 'getFromProjectLookup')
           .returns(Promise.resolve('test'));
       const sendStub =
-          sandbox.stub(element, '_send').returns(Promise.resolve());
+          sandbox.stub(element._restApiHelper, 'send').returns(Promise.resolve());
 
       await element.saveChangeStarred(123, true);
       assert.isTrue(sendStub.calledOnce);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
new file mode 100644
index 0000000..d42abc3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
@@ -0,0 +1,456 @@
+/**
+ * @license
+ * 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.
+ */
+(function(window) {
+  'use strict';
+
+  const Defs = {};
+
+  /**
+   * @typedef {{
+   *    url: string,
+   *    fetchOptions: (Object|null|undefined),
+   *    anonymizedUrl: (string|undefined),
+   * }}
+   */
+  Defs.FetchRequest;
+
+  /**
+   * Object to describe a request for passing into fetchJSON or fetchRawJSON.
+   * - url is the URL for the request (excluding get params)
+   * - errFn is a function to invoke when the request fails.
+   * - cancelCondition is a function that, if provided and returns true, will
+   *     cancel the response after it resolves.
+   * - params is a key-value hash to specify get params for the request URL.
+   * @typedef {{
+   *    url: string,
+   *    errFn: (function(?Response, string=)|null|undefined),
+   *    cancelCondition: (function()|null|undefined),
+   *    params: (Object|null|undefined),
+   *    fetchOptions: (Object|null|undefined),
+   *    anonymizedUrl: (string|undefined),
+   *    reportUrlAsIs: (boolean|undefined),
+   * }}
+   */
+  Defs.FetchJSONRequest;
+
+  const JSON_PREFIX = ')]}\'';
+  const FAILED_TO_FETCH_ERROR = 'Failed to fetch';
+
+  /**
+   * Wrapper around Map for caching server responses. Site-based so that
+   * changes to CANONICAL_PATH will result in a different cache going into
+   * effect.
+   */
+  class SiteBasedCache {
+    constructor() {
+      // Container of per-canonical-path caches.
+      this._data = new Map();
+      if (window.INITIAL_DATA != undefined) {
+        // Put all data shipped with index.html into the cache. This makes it
+        // so that we spare more round trips to the server when the app loads
+        // initially.
+        Object
+            .entries(window.INITIAL_DATA)
+            .forEach(e => this._cache().set(e[0], e[1]));
+      }
+    }
+
+    // Returns the cache for the current canonical path.
+    _cache() {
+      if (!this._data.has(window.CANONICAL_PATH)) {
+        this._data.set(window.CANONICAL_PATH, new Map());
+      }
+      return this._data.get(window.CANONICAL_PATH);
+    }
+
+    has(key) {
+      return this._cache().has(key);
+    }
+
+    get(key) {
+      return this._cache().get(key);
+    }
+
+    set(key, value) {
+      this._cache().set(key, value);
+    }
+
+    delete(key) {
+      this._cache().delete(key);
+    }
+
+    invalidatePrefix(prefix) {
+      const newMap = new Map();
+      for (const [key, value] of this._cache().entries()) {
+        if (!key.startsWith(prefix)) {
+          newMap.set(key, value);
+        }
+      }
+      this._data.set(window.CANONICAL_PATH, newMap);
+    }
+  }
+
+  class FetchPromisesCache {
+    constructor() {
+      this._data = {};
+    }
+
+    has(key) {
+      return !!this._data[key];
+    }
+
+    get(key) {
+      return this._data[key];
+    }
+
+    set(key, value) {
+      this._data[key] = value;
+    }
+
+    invalidatePrefix(prefix) {
+      const newData = {};
+      Object.entries(this._data).forEach(([key, value]) => {
+        if (!key.startsWith(prefix)) {
+          newData[key] = value;
+        }
+      });
+      this._data = newData;
+    }
+  }
+
+  class GrRestApiHelper {
+    /**
+     * @param {SiteBasedCache} cache
+     * @param {object} auth
+     * @param {FetchPromisesCache} fetchPromisesCache
+     * @param {object} credentialCheck
+     * @param {object} restApiInterface
+     */
+    constructor(cache, auth, fetchPromisesCache, credentialCheck,
+        restApiInterface) {
+      this._cache = cache;// TODO: make it public
+      this._auth = auth;
+      this._fetchPromisesCache = fetchPromisesCache;
+      this._credentialCheck = credentialCheck;
+      this._restApiInterface = restApiInterface;
+    }
+
+    /**
+     * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
+     * with timing and logging.
+     * @param {Defs.FetchRequest} req
+     */
+    fetch(req) {
+      const start = Date.now();
+      const xhr = this._auth.fetch(req.url, req.fetchOptions);
+
+      // Log the call after it completes.
+      xhr.then(res => this._logCall(req, start, res ? res.status : null));
+
+      // Return the XHR directly (without the log).
+      return xhr;
+    }
+
+    /**
+     * Log information about a REST call. Because the elapsed time is determined
+     * by this method, it should be called immediately after the request
+     * finishes.
+     * @param {Defs.FetchRequest} req
+     * @param {number} startTime the time that the request was started.
+     * @param {number} status the HTTP status of the response. The status value
+     *     is used here rather than the response object so there is no way this
+     *     method can read the body stream.
+     */
+    _logCall(req, startTime, status) {
+      const method = (req.fetchOptions && req.fetchOptions.method) ?
+          req.fetchOptions.method : 'GET';
+      const endTime = Date.now();
+      const elapsed = (endTime - startTime);
+      const startAt = new Date(startTime);
+      const endAt = new Date(endTime);
+      console.log([
+        'HTTP',
+        status,
+        method,
+        elapsed + 'ms',
+        req.anonymizedUrl || req.url,
+        `(${startAt.toISOString()}, ${endAt.toISOString()})`,
+      ].join(' '));
+      if (req.anonymizedUrl) {
+        this.fire('rpc-log',
+            {status, method, elapsed, anonymizedUrl: req.anonymizedUrl});
+      }
+    }
+
+    /**
+     * Fetch JSON from url provided.
+     * Returns a Promise that resolves to a native Response.
+     * Doesn't do error checking. Supports cancel condition. Performs auth.
+     * Validates auth expiry errors.
+     * @param {Defs.FetchJSONRequest} req
+     */
+    fetchRawJSON(req) {
+      const urlWithParams = this.urlWithParams(req.url, req.params);
+      const fetchReq = {
+        url: urlWithParams,
+        fetchOptions: req.fetchOptions,
+        anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl,
+      };
+      return this.fetch(fetchReq).then(res => {
+        if (req.cancelCondition && req.cancelCondition()) {
+          res.body.cancel();
+          return;
+        }
+        return res;
+      }).catch(err => {
+        const isLoggedIn = !!this._cache.get('/accounts/self/detail');
+        if (isLoggedIn && err && err.message === FAILED_TO_FETCH_ERROR) {
+          this.checkCredentials();
+        } else {
+          if (req.errFn) {
+            req.errFn.call(undefined, null, err);
+          } else {
+            this.fire('network-error', {error: err});
+          }
+        }
+        throw err;
+      });
+    }
+
+    /**
+     * Fetch JSON from url provided.
+     * Returns a Promise that resolves to a parsed response.
+     * Same as {@link fetchRawJSON}, plus error handling.
+     * @param {Defs.FetchJSONRequest} req
+     */
+    fetchJSON(req) {
+      req = this.addAcceptJsonHeader(req);
+      return this.fetchRawJSON(req).then(response => {
+        if (!response) {
+          return;
+        }
+        if (!response.ok) {
+          if (req.errFn) {
+            req.errFn.call(null, response);
+            return;
+          }
+          this.fire('server-error', {request: req, response});
+          return;
+        }
+        return response && this.getResponseObject(response);
+      });
+    }
+
+    /**
+     * @param {string} url
+     * @param {?Object|string=} opt_params URL params, key-value hash.
+     * @return {string}
+     */
+    urlWithParams(url, opt_params) {
+      if (!opt_params) { return this.getBaseUrl() + url; }
+
+      const params = [];
+      for (const p in opt_params) {
+        if (!opt_params.hasOwnProperty(p)) { continue; }
+        if (opt_params[p] == null) {
+          params.push(encodeURIComponent(p));
+          continue;
+        }
+        for (const value of [].concat(opt_params[p])) {
+          params.push(`${encodeURIComponent(p)}=${encodeURIComponent(value)}`);
+        }
+      }
+      return this.getBaseUrl() + url + '?' + params.join('&');
+    }
+
+    /**
+     * @param {!Object} response
+     * @return {?}
+     */
+    getResponseObject(response) {
+      return this.readResponsePayload(response)
+          .then(payload => payload.parsed);
+    }
+
+    /**
+     * @param {!Object} response
+     * @return {!Object}
+     */
+    readResponsePayload(response) {
+      return response.text().then(text => {
+        let result;
+        try {
+          result = this.parsePrefixedJSON(text);
+        } catch (_) {
+          result = null;
+        }
+        return {parsed: result, raw: text};
+      });
+    }
+
+    /**
+     * @param {string} source
+     * @return {?}
+     */
+    parsePrefixedJSON(source) {
+      return JSON.parse(source.substring(JSON_PREFIX.length));
+    }
+
+    /**
+     * @param {Defs.FetchJSONRequest} req
+     * @return {Defs.FetchJSONRequest}
+     */
+    addAcceptJsonHeader(req) {
+      if (!req.fetchOptions) req.fetchOptions = {};
+      if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
+      if (!req.fetchOptions.headers.has('Accept')) {
+        req.fetchOptions.headers.append('Accept', 'application/json');
+      }
+      return req;
+    }
+
+    getBaseUrl() {
+      return this._restApiInterface.getBaseUrl();
+    }
+
+    fire(type, detail, options) {
+      return this._restApiInterface.fire(type, detail, options);
+    }
+
+    /**
+     * @param {Defs.FetchJSONRequest} req
+     */
+    fetchCacheURL(req) {
+      if (this._fetchPromisesCache.has(req.url)) {
+        return this._fetchPromisesCache.get(req.url);
+      }
+      // TODO(andybons): Periodic cache invalidation.
+      if (this._cache.has(req.url)) {
+        return Promise.resolve(this._cache.get(req.url));
+      }
+      this._fetchPromisesCache.set(req.url,
+          this.fetchJSON(req).then(response => {
+            if (response !== undefined) {
+              this._cache.set(req.url, response);
+            }
+            this._fetchPromisesCache.set(req.url, undefined);
+            return response;
+          }).catch(err => {
+            this._fetchPromisesCache.set(req.url, undefined);
+            throw err;
+          })
+      );
+      return this._fetchPromisesCache.get(req.url);
+    }
+
+    /**
+     * Send an XHR.
+     * @param {Defs.SendRequest} req
+     * @return {Promise}
+     */
+    send(req) {
+      const options = {method: req.method};
+      if (req.body) {
+        options.headers = new Headers();
+        options.headers.set(
+            'Content-Type', req.contentType || 'application/json');
+        options.body = typeof req.body === 'string' ?
+            req.body : JSON.stringify(req.body);
+      }
+      if (req.headers) {
+        if (!options.headers) { options.headers = new Headers(); }
+        for (const header in req.headers) {
+          if (!req.headers.hasOwnProperty(header)) { continue; }
+          options.headers.set(header, req.headers[header]);
+        }
+      }
+      const url = req.url.startsWith('http') ?
+          req.url : this.getBaseUrl() + req.url;
+      const fetchReq = {
+        url,
+        fetchOptions: options,
+        anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
+      };
+      const xhr = this.fetch(fetchReq).then(response => {
+        if (!response.ok) {
+          if (req.errFn) {
+            return req.errFn.call(undefined, response);
+          }
+          this.fire('server-error', {request: fetchReq, response});
+        }
+        return response;
+      }).catch(err => {
+        this.fire('network-error', {error: err});
+        if (req.errFn) {
+          return req.errFn.call(undefined, null, err);
+        } else {
+          throw err;
+        }
+      });
+
+      if (req.parseResponse) {
+        return xhr.then(res => this.getResponseObject(res));
+      }
+
+      return xhr;
+    }
+
+    checkCredentials() {
+      if (this._credentialCheck.checking) {
+        return;
+      }
+      this._credentialCheck.checking = true;
+      let req = {url: '/accounts/self/detail', reportUrlAsIs: true};
+      req = this.addAcceptJsonHeader(req);
+      // Skip the REST response cache.
+      return this.fetchRawJSON(req).then(res => {
+        if (!res) { return; }
+        if (res.status === 403) {
+          this.fire('auth-error');
+          this._cache.delete('/accounts/self/detail');
+        } else if (res.ok) {
+          return this.getResponseObject(res);
+        }
+      }).then(res => {
+        this._credentialCheck.checking = false;
+        if (res) {
+          this._cache.set('/accounts/self/detail', res);
+        }
+        return res;
+      }).catch(err => {
+        this._credentialCheck.checking = false;
+        if (err && err.message === FAILED_TO_FETCH_ERROR) {
+          this.fire('auth-error');
+          this._cache.delete('/accounts/self/detail');
+        }
+      });
+    }
+
+    /**
+     * @param {string} prefix
+     */
+    invalidateFetchPromisesPrefix(prefix) {
+      this._fetchPromisesCache.invalidatePrefix(prefix);
+      this._cache.invalidatePrefix(prefix);
+    }
+  }
+
+  window.SiteBasedCache = SiteBasedCache;
+  window.FetchPromisesCache = FetchPromisesCache;
+  window.GrRestApiHelper = GrRestApiHelper;
+})(window);
+
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html
new file mode 100644
index 0000000..4eaf1bc
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html
@@ -0,0 +1,177 @@
+<!DOCTYPE html>
+<!--
+@license
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-rest-api-helper</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../../test/common-test-setup.html"/>
+<script src="../../../../scripts/util.js"></script>
+<script src="../gr-auth.js"></script>
+<script src="gr-rest-api-helper.js"></script>
+
+<script>void(0);</script>
+
+<script>
+  suite('gr-rest-api-helper tests', () => {
+    let helper;
+    let sandbox;
+    let cache;
+    let fetchPromisesCache;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      cache = new SiteBasedCache();
+      fetchPromisesCache = new FetchPromisesCache();
+      const credentialCheck = {checking: false};
+
+      window.CANONICAL_PATH = 'testhelper';
+
+      const mockRestApiInterface = {
+        getBaseUrl: sinon.stub().returns(window.CANONICAL_PATH),
+        fire: sinon.stub(),
+      };
+
+      const testJSON = ')]}\'\n{"hello": "bonjour"}';
+      sandbox.stub(window, 'fetch').returns(Promise.resolve({
+        ok: true,
+        text() {
+          return Promise.resolve(testJSON);
+        },
+      }));
+
+      helper = new GrRestApiHelper(cache, Gerrit.Auth, fetchPromisesCache,
+          credentialCheck, mockRestApiInterface);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('fetchJSON()', () => {
+      test('Sets header to accept application/json', () => {
+        const authFetchStub = sandbox.stub(helper._auth, 'fetch')
+            .returns(Promise.resolve());
+        helper.fetchJSON({url: '/dummy/url'});
+        assert.isTrue(authFetchStub.called);
+        assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
+            'application/json');
+      });
+
+      test('Use header option accept when provided', () => {
+        const authFetchStub = sandbox.stub(helper._auth, 'fetch')
+            .returns(Promise.resolve());
+        const headers = new Headers();
+        headers.append('Accept', '*/*');
+        const fetchOptions = {headers};
+        helper.fetchJSON({url: '/dummy/url', fetchOptions});
+        assert.isTrue(authFetchStub.called);
+        assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
+            '*/*');
+      });
+    });
+
+    test('JSON prefix is properly removed', done => {
+      helper.fetchJSON({url: '/dummy/url'}).then(obj => {
+        assert.deepEqual(obj, {hello: 'bonjour'});
+        done();
+      });
+    });
+
+    test('cached results', done => {
+      let n = 0;
+      sandbox.stub(helper, 'fetchJSON', () => {
+        return Promise.resolve(++n);
+      });
+      const promises = [];
+      promises.push(helper.fetchCacheURL('/foo'));
+      promises.push(helper.fetchCacheURL('/foo'));
+      promises.push(helper.fetchCacheURL('/foo'));
+
+      Promise.all(promises).then(results => {
+        assert.deepEqual(results, [1, 1, 1]);
+        helper.fetchCacheURL('/foo').then(foo => {
+          assert.equal(foo, 1);
+          done();
+        });
+      });
+    });
+
+    test('cached promise', done => {
+      const promise = Promise.reject(new Error('foo'));
+      cache.set('/foo', promise);
+      helper.fetchCacheURL({url: '/foo'}).catch(p => {
+        assert.equal(p.message, 'foo');
+        done();
+      });
+    });
+
+    test('cache invalidation', () => {
+      cache.set('/foo/bar', 1);
+      cache.set('/bar', 2);
+      fetchPromisesCache.set('/foo/bar', 3);
+      fetchPromisesCache.set('/bar', 4);
+      helper.invalidateFetchPromisesPrefix('/foo/');
+      assert.isFalse(cache.has('/foo/bar'));
+      assert.isTrue(cache.has('/bar'));
+      assert.isUndefined(fetchPromisesCache.get('/foo/bar'));
+      assert.strictEqual(4, fetchPromisesCache.get('/bar'));
+    });
+
+    test('params are properly encoded', () => {
+      let url = helper.urlWithParams('/path/', {
+        sp: 'hola',
+        gr: 'guten tag',
+        noval: null,
+      });
+      assert.equal(url,
+          window.CANONICAL_PATH + '/path/?sp=hola&gr=guten%20tag&noval');
+
+      url = helper.urlWithParams('/path/', {
+        sp: 'hola',
+        en: ['hey', 'hi'],
+      });
+      assert.equal(url, window.CANONICAL_PATH + '/path/?sp=hola&en=hey&en=hi');
+
+      // Order must be maintained with array params.
+      url = helper.urlWithParams('/path/', {
+        l: ['c', 'b', 'a'],
+      });
+      assert.equal(url, window.CANONICAL_PATH + '/path/?l=c&l=b&l=a');
+    });
+
+    test('request callbacks can be canceled', done => {
+      let cancelCalled = false;
+      window.fetch.returns(Promise.resolve({
+        body: {
+          cancel() { cancelCalled = true; },
+        },
+      }));
+      const cancelCondition = () => { return true; };
+      helper.fetchJSON({url: '/dummy/url', cancelCondition}).then(
+          obj => {
+            assert.isUndefined(obj);
+            assert.isTrue(cancelCalled);
+            done();
+          });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
index 357ab40..ecf542f 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
@@ -60,6 +60,10 @@
       this.bindValue = this.nativeSelect.value;
     },
 
+    focus() {
+      this.nativeSelect.focus();
+    },
+
     ready() {
       // If not set via the property, set bind-value to the element value.
       if (this.bindValue == undefined) {
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
index 131dc79..fa54dba 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
@@ -55,6 +55,9 @@
       iron-autogrow-textarea {
         padding: 2px;
         position: relative;
+
+        /** This is needed for firefox */
+        --iron-autogrow-textarea_-_white-space: pre-wrap;
       }
       #textarea.noBorder {
         border: none;
diff --git a/polygerrit-ui/app/elements/test/plugin.html b/polygerrit-ui/app/elements/test/plugin.html
index a0d7467..ecd9007 100644
--- a/polygerrit-ui/app/elements/test/plugin.html
+++ b/polygerrit-ui/app/elements/test/plugin.html
@@ -1,8 +1,10 @@
 <dom-module id="my-plugin">
   <script>
-    Gerrit.install(plugin =>
-      plugin.registerStyleModule('app-theme', 'myplugin-app-theme')
-    );
+    Gerrit.install(plugin => {
+      plugin.registerStyleModule('app-theme', 'myplugin-app-theme');
+      plugin.registerStyleModule('app-theme-light', 'myplugin-app-theme-light');
+      plugin.registerStyleModule('app-theme-dark', 'myplugin-app-theme-dark');
+    });
   </script>
 </dom-module>
 
@@ -11,6 +13,15 @@
     <style>
       html {
         --primary-text-color: #F00BAA;
+      }
+    </style>
+  </template>
+</dom-module>
+
+<dom-module id="myplugin-app-theme-light">
+  <template>
+    <style>
+      html {
         --header-background-color: #F01BAA;
         --header-title-content: "MyGerrit";
         --footer-background-color: #F02BAA;
@@ -18,3 +29,16 @@
     </style>
   </template>
 </dom-module>
+
+<dom-module id="myplugin-app-theme-dark">
+  <template>
+    <style>
+      html {
+        --primary-text-color: red;
+        --header-background-color: black;
+        --header-title-content: "MyGerrit Dark";
+        --footer-background-color: yellow;
+      }
+    </style>
+  </template>
+</dom-module>
diff --git a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils.js b/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils.js
new file mode 100644
index 0000000..238cf15
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils.js
@@ -0,0 +1,55 @@
+(function(window) {
+  'use strict';
+
+  if (window.GrDisplayNameUtils) {
+    return;
+  }
+
+  const ANONYMOUS_NAME = 'Anonymous';
+
+  class GrDisplayNameUtils {
+    /**
+     * enableEmail when true enables to fallback to using email if
+     * the account name is not avilable.
+     */
+    static getUserName(config, account, enableEmail) {
+      if (account && account.name) {
+        return account.name;
+      } else if (account && account.username) {
+        return account.username;
+      } else if (enableEmail && account && account.email) {
+        return account.email;
+      } else if (config && config.user &&
+          config.user.anonymous_coward_name !== 'Anonymous Coward') {
+        return config.user.anonymous_coward_name;
+      }
+
+      return ANONYMOUS_NAME;
+    }
+
+    static getAccountDisplayName(config, account, enableEmail) {
+      const reviewerName = this._accountOrAnon(config, account, enableEmail);
+      const reviewerEmail = this._accountEmail(account.email);
+      const reviewerStatus = account.status ? '(' + account.status + ')' : '';
+      return [reviewerName, reviewerEmail, reviewerStatus]
+          .filter(p => p.length > 0).join(' ');
+    }
+
+    static _accountOrAnon(config, reviewer, enableEmail) {
+      return this.getUserName(config, reviewer, !!enableEmail);
+    }
+
+    static _accountEmail(email) {
+      if (typeof email !== 'undefined') {
+        return '<' + email + '>';
+      }
+      return '';
+    }
+
+    static getGroupDisplayName(group) {
+      return group.name + ' (group)';
+    }
+  }
+
+  window.GrDisplayNameUtils = GrDisplayNameUtils;
+})(window);
diff --git a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html b/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html
new file mode 100644
index 0000000..25ca4c5
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html
@@ -0,0 +1,140 @@
+<!DOCTYPE html>
+<!--
+@license
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-display-name-utils</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../test/common-test-setup.html"/>
+<script src="gr-display-name-utils.js"></script>
+
+<script>
+  suite('gr-display-name-utils tests', () => {
+    // eslint-disable-next-line no-unused-vars
+    const config = {
+      user: {
+        anonymous_coward_name: 'Anonymous Coward',
+      },
+    };
+
+
+    test('getUserName name only', () => {
+      const account = {
+        name: 'test-name',
+      };
+      assert.deepEqual(GrDisplayNameUtils.getUserName(config, account, true),
+          'test-name');
+    });
+
+    test('getUserName username only', () => {
+      const account = {
+        username: 'test-user',
+      };
+      assert.deepEqual(GrDisplayNameUtils.getUserName(config, account, true),
+          'test-user');
+    });
+
+    test('getUserName email only', () => {
+      const account = {
+        email: 'test-user@test-url.com',
+      };
+      assert.deepEqual(GrDisplayNameUtils.getUserName(config, account, true),
+          'test-user@test-url.com');
+    });
+
+    test('getUserName returns not Anonymous Coward as the anon name', () => {
+      assert.deepEqual(GrDisplayNameUtils.getUserName(config, null, true),
+          'Anonymous');
+    });
+
+    test('getUserName for the config returning the anon name', () => {
+      const config = {
+        user: {
+          anonymous_coward_name: 'Test Anon',
+        },
+      };
+      assert.deepEqual(GrDisplayNameUtils.getUserName(config, null, true),
+          'Test Anon');
+    });
+
+    test('getAccountDisplayName - account with name only', () => {
+      assert.equal(
+          GrDisplayNameUtils.getAccountDisplayName(config,
+              {name: 'Some user name'}),
+          'Some user name');
+    });
+
+    test('getAccountDisplayName - account with email only', () => {
+      assert.equal(
+          GrDisplayNameUtils.getAccountDisplayName(config,
+              {email: 'my@example.com'}),
+          'Anonymous <my@example.com>');
+    });
+
+    test('getAccountDisplayName - account with email only - allowEmail', () => {
+      assert.equal(
+          GrDisplayNameUtils.getAccountDisplayName(config,
+              {email: 'my@example.com'}, true),
+          'my@example.com <my@example.com>');
+    });
+
+    test('getAccountDisplayName - account with name and status', () => {
+      assert.equal(
+          GrDisplayNameUtils.getAccountDisplayName(config, {
+            name: 'Some name',
+            status: 'OOO',
+          }),
+          'Some name (OOO)');
+    });
+
+    test('getAccountDisplayName - account with name and email', () => {
+      assert.equal(
+          GrDisplayNameUtils.getAccountDisplayName(config, {
+            name: 'Some name',
+            email: 'my@example.com',
+          }),
+          'Some name <my@example.com>');
+    });
+
+    test('getAccountDisplayName - account with name, email and status', () => {
+      assert.equal(
+          GrDisplayNameUtils.getAccountDisplayName(config, {
+            name: 'Some name',
+            email: 'my@example.com',
+            status: 'OOO',
+          }),
+          'Some name <my@example.com> (OOO)');
+    });
+
+    test('getGroupDisplayName', () => {
+      assert.equal(
+          GrDisplayNameUtils.getGroupDisplayName({name: 'Some user name'}),
+          'Some user name (group)');
+    });
+
+    test('_accountEmail', () => {
+      assert.equal(
+          GrDisplayNameUtils._accountEmail('email@gerritreview.com'),
+          '<email@gerritreview.com>');
+      assert.equal(GrDisplayNameUtils._accountEmail(undefined), '');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js
new file mode 100644
index 0000000..67001d2
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * 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.
+ */
+(function(window) {
+  'use strict';
+
+  if (window.GrEmailSuggestionsProvider) {
+    return;
+  }
+
+  class GrEmailSuggestionsProvider {
+    constructor(restAPI) {
+      this._restAPI = restAPI;
+    }
+
+    getSuggestions(input) {
+      return this._restAPI.getSuggestedAccounts(`${input}`)
+          .then(accounts => {
+            if (!accounts) { return []; }
+            return accounts;
+          });
+    }
+
+    makeSuggestionItem(account) {
+      return {
+        name: GrDisplayNameUtils.getAccountDisplayName(null, account, true),
+        value: {account, count: 1},
+      };
+    }
+  }
+
+  window.GrEmailSuggestionsProvider = GrEmailSuggestionsProvider;
+})(window);
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.html b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.html
new file mode 100644
index 0000000..fb6b5d4
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.html
@@ -0,0 +1,99 @@
+<!DOCTYPE html>
+<!--
+@license
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-email-suggestions-provider</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.html"/>
+<script src="../gr-display-name-utils/gr-display-name-utils.js"></script>
+<script src="gr-email-suggestions-provider.js"></script>
+
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+</test-fixture>
+
+<script>
+  suite('GrEmailSuggestionsProvider tests', () => {
+    let sandbox;
+    let restAPI;
+    let provider;
+    const account1 = {
+      name: 'Some name',
+      email: 'some@example.com',
+    };
+    const account2 = {
+      email: 'other@example.com',
+      _account_id: 3,
+    };
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({}); },
+      });
+      restAPI = fixture('basic');
+      provider = new GrEmailSuggestionsProvider(restAPI);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('getSuggestions', done => {
+      const getSuggestedAccountsStub =
+          sandbox.stub(restAPI, 'getSuggestedAccounts')
+            .returns(Promise.resolve([account1, account2]));
+
+      provider.getSuggestions('Some input').then(res => {
+        assert.deepEqual(res, [account1, account2]);
+        assert.isTrue(getSuggestedAccountsStub.calledOnce);
+        assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
+        done();
+      });
+    });
+
+    test('makeSuggestionItem', () => {
+      assert.deepEqual(provider.makeSuggestionItem(account1), {
+        name: 'Some name <some@example.com>',
+        value: {
+          account: account1,
+          count: 1,
+        },
+      });
+
+      assert.deepEqual(provider.makeSuggestionItem(account2), {
+        name: 'other@example.com <other@example.com>',
+        value: {
+          account: account2,
+          count: 1,
+        },
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js
new file mode 100644
index 0000000..a95670b
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js
@@ -0,0 +1,47 @@
+/**
+ * @license
+ * 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.
+ */
+(function(window) {
+  'use strict';
+
+  if (window.GrGroupSuggestionsProvider) {
+    return;
+  }
+
+  class GrGroupSuggestionsProvider {
+    constructor(restAPI) {
+      this._restAPI = restAPI;
+    }
+
+    getSuggestions(input) {
+      return this._restAPI.getSuggestedGroups(`${input}`)
+          .then(groups => {
+            if (!groups) { return []; }
+            const keys = Object.keys(groups);
+            return keys.map(key => {
+              return Object.assign({}, groups[key], {name: key});
+            });
+          });
+    }
+
+    makeSuggestionItem(suggestion) {
+      return {name: suggestion.name,
+        value: {group: {name: suggestion.name, id: suggestion.id}}};
+    }
+  }
+
+  window.GrGroupSuggestionsProvider = GrGroupSuggestionsProvider;
+})(window);
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.html b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.html
new file mode 100644
index 0000000..b60aaa9
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.html
@@ -0,0 +1,106 @@
+<!DOCTYPE html>
+<!--
+@license
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-group-suggestions-provider</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.html"/>
+<script src="../gr-display-name-utils/gr-display-name-utils.js"></script>
+<script src="gr-group-suggestions-provider.js"></script>
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+</test-fixture>
+
+<script>
+  suite('GrGroupSuggestionsProvider tests', () => {
+    let sandbox;
+    let restAPI;
+    let provider;
+    const group1 = {
+      name: 'Some name',
+      id: 1,
+    };
+    const group2 = {
+      name: 'Other name',
+      id: 3,
+      url: 'abcd',
+    };
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({}); },
+      });
+      restAPI = fixture('basic');
+      provider = new GrGroupSuggestionsProvider(restAPI);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('getSuggestions', done => {
+      const getSuggestedAccountsStub =
+          sandbox.stub(restAPI, 'getSuggestedGroups')
+              .returns(Promise.resolve({
+                'Some name': {id: 1},
+                'Other name': {id: 3, url: 'abcd'},
+              }));
+
+      provider.getSuggestions('Some input').then(res => {
+        assert.deepEqual(res, [group1, group2]);
+        assert.isTrue(getSuggestedAccountsStub.calledOnce);
+        assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
+        done();
+      });
+    });
+
+    test('makeSuggestionItem', () => {
+      assert.deepEqual(provider.makeSuggestionItem(group1), {
+        name: 'Some name',
+        value: {
+          group: {
+            name: 'Some name',
+            id: 1,
+          },
+        },
+      });
+
+      assert.deepEqual(provider.makeSuggestionItem(group2), {
+        name: 'Other name',
+        value: {
+          group: {
+            name: 'Other name',
+            id: 3,
+          },
+        },
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js
new file mode 100644
index 0000000..7f1a9b1
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js
@@ -0,0 +1,90 @@
+/**
+ * @license
+ * 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.
+ */
+(function(window) {
+  'use strict';
+
+  if (window.GrReviewerSuggestionsProvider) {
+    return;
+  }
+
+  class GrReviewerSuggestionsProvider {
+    constructor(restAPI, changeNumber, allowAnyUser) {
+      this._changeNumber = changeNumber;
+      this._allowAnyUser = allowAnyUser;
+      this._restAPI = restAPI;
+    }
+
+    init() {
+      if (this._initPromise) {
+        return this._initPromise;
+      }
+      const getConfigPromise = this._restAPI.getConfig().then(cfg => {
+        this._config = cfg;
+      });
+      const getLoggedInPromise = this._restAPI.getLoggedIn().then(loggedIn => {
+        this._loggedIn = loggedIn;
+      });
+      this._initPromise = Promise.all([getConfigPromise, getLoggedInPromise])
+        .then(() => {
+          this._initialized = true;
+        });
+      return this._initPromise;
+    }
+
+    getSuggestions(input) {
+      if (!this._initialized || !this._loggedIn) {
+        return Promise.resolve([]);
+      }
+      const api = this._restAPI;
+      const xhr = this._allowAnyUser ?
+          api.getSuggestedAccounts(`cansee:${this._changeNumber} ${input}`) :
+          api.getChangeSuggestedReviewers(this._changeNumber, input);
+
+      return xhr.then(reviewers => (reviewers || []));
+    }
+
+    makeSuggestionItem(suggestion) {
+      if (suggestion.account) {
+        // Reviewer is an account suggestion from getChangeSuggestedReviewers.
+        return {
+          name: GrDisplayNameUtils.getAccountDisplayName(this._config,
+              suggestion.account, false),
+          value: suggestion,
+        };
+      }
+
+      if (suggestion.group) {
+        // Reviewer is a group suggestion from getChangeSuggestedReviewers.
+        return {
+          name: GrDisplayNameUtils.getGroupDisplayName(suggestion.group),
+          value: suggestion,
+        };
+      }
+
+      if (suggestion._account_id) {
+        // Reviewer is an account suggestion from getSuggestedAccounts.
+        return {
+          name: GrDisplayNameUtils.getAccountDisplayName(this._config,
+              suggestion, false),
+          value: {account: suggestion, count: 1},
+        };
+      }
+    }
+  }
+
+  window.GrReviewerSuggestionsProvider = GrReviewerSuggestionsProvider;
+})(window);
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html
new file mode 100644
index 0000000..bb73520
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html
@@ -0,0 +1,260 @@
+<!DOCTYPE html>
+<!--
+@license
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-reviewer-suggestions-provider</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.html"/>
+<script src="../gr-display-name-utils/gr-display-name-utils.js"></script>
+<script src="gr-reviewer-suggestions-provider.js"></script>
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+</test-fixture>
+
+<script>
+  suite('GrReviewerSuggestionsProvider tests', () => {
+    let sandbox;
+    let _nextAccountId = 0;
+    const makeAccount = function(opt_status) {
+      const accountId = ++_nextAccountId;
+      return {
+        _account_id: accountId,
+        name: 'name ' + accountId,
+        email: 'email ' + accountId,
+        status: opt_status,
+      };
+    };
+    let _nextAccountId2 = 0;
+    const makeAccount2 = function(opt_status) {
+      const accountId2 = ++_nextAccountId2;
+      return {
+        _account_id: accountId2,
+        name: 'name ' + accountId2,
+        status: opt_status,
+      };
+    };
+
+    let owner;
+    let existingReviewer1;
+    let existingReviewer2;
+    let suggestion1;
+    let suggestion2;
+    let suggestion3;
+    let restAPI;
+    let provider;
+
+    let redundantSuggestion1;
+    let redundantSuggestion2;
+    let redundantSuggestion3;
+    let change;
+
+    setup(done => {
+      owner = makeAccount();
+      existingReviewer1 = makeAccount();
+      existingReviewer2 = makeAccount();
+      suggestion1 = {account: makeAccount()};
+      suggestion2 = {account: makeAccount()};
+      suggestion3 = {
+        group: {
+          id: 'suggested group id',
+          name: 'suggested group',
+        },
+      };
+
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+        getConfig() { return Promise.resolve({}); },
+      });
+
+      restAPI = fixture('basic');
+      change = {
+        _number: 42,
+        owner,
+        reviewers: {
+          CC: [existingReviewer1],
+          REVIEWER: [existingReviewer2],
+        },
+      };
+      sandbox = sinon.sandbox.create();
+      return flush(done);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+    suite('allowAnyUser set to false', () => {
+      setup(done => {
+        provider = new GrReviewerSuggestionsProvider(restAPI, change._number,
+            false);
+        provider.init().then(done);
+      });
+      suite('stubbed values for _getReviewerSuggestions', () => {
+        setup(() => {
+          stub('gr-rest-api-interface', {
+            getChangeSuggestedReviewers() {
+              redundantSuggestion1 = {account: existingReviewer1};
+              redundantSuggestion2 = {account: existingReviewer2};
+              redundantSuggestion3 = {account: owner};
+              return Promise.resolve([redundantSuggestion1, redundantSuggestion2,
+                redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
+            },
+          });
+        });
+
+        test('makeSuggestionItem formats account or group accordingly', () => {
+          let account = makeAccount();
+          const account3 = makeAccount2();
+          let suggestion = provider.makeSuggestionItem({account});
+          assert.deepEqual(suggestion, {
+            name: account.name + ' <' + account.email + '>',
+            value: {account},
+          });
+
+          const group = {name: 'test'};
+          suggestion = provider.makeSuggestionItem({group});
+          assert.deepEqual(suggestion, {
+            name: group.name + ' (group)',
+            value: {group},
+          });
+
+          suggestion = provider.makeSuggestionItem(account);
+          assert.deepEqual(suggestion, {
+            name: account.name + ' <' + account.email + '>',
+            value: {account, count: 1},
+          });
+
+          suggestion = provider.makeSuggestionItem({account: {}});
+          assert.deepEqual(suggestion, {
+            name: 'Anonymous',
+            value: {account: {}},
+          });
+
+          provider._config = {
+            user: {
+              anonymous_coward_name: 'Anonymous Coward Name',
+            },
+          };
+
+          suggestion = provider.makeSuggestionItem({account: {}});
+          assert.deepEqual(suggestion, {
+            name: 'Anonymous Coward Name',
+            value: {account: {}},
+          });
+
+          account = makeAccount('OOO');
+
+          suggestion = provider.makeSuggestionItem({account});
+          assert.deepEqual(suggestion, {
+            name: account.name + ' <' + account.email + '> (OOO)',
+            value: {account},
+          });
+
+          suggestion = provider.makeSuggestionItem(account);
+          assert.deepEqual(suggestion, {
+            name: account.name + ' <' + account.email + '> (OOO)',
+            value: {account, count: 1},
+          });
+
+          sandbox.stub(GrDisplayNameUtils, '_accountEmail',
+              () => {
+                return '';
+              });
+
+          suggestion = provider.makeSuggestionItem(account3);
+          assert.deepEqual(suggestion, {
+            name: account3.name,
+            value: {account: account3, count: 1},
+          });
+        });
+
+        test('getSuggestions', done => {
+          provider.getSuggestions().then(reviewers => {
+            // Default is no filtering.
+            assert.equal(reviewers.length, 6);
+            assert.deepEqual(reviewers,
+                [redundantSuggestion1, redundantSuggestion2,
+                  redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
+          }).then(done);
+        });
+
+        test('getSuggestions short circuits when logged out', () => {
+          // API call is already stubbed.
+          const xhrSpy = restAPI.getChangeSuggestedReviewers;
+          provider._loggedIn = false;
+          return provider.getSuggestions('').then(() => {
+            assert.isFalse(xhrSpy.called);
+            provider._loggedIn = true;
+            return provider.getSuggestions('').then(() => {
+              assert.isTrue(xhrSpy.called);
+            });
+          });
+        });
+      });
+
+      test('getChangeSuggestedReviewers is used', done => {
+        const suggestReviewerStub =
+            sandbox.stub(restAPI, 'getChangeSuggestedReviewers')
+                .returns(Promise.resolve([]));
+        const suggestAccountStub =
+            sandbox.stub(restAPI, 'getSuggestedAccounts')
+                .returns(Promise.resolve([]));
+
+        provider.getSuggestions('').then(() => {
+          assert.isTrue(suggestReviewerStub.calledOnce);
+          assert.isTrue(suggestReviewerStub.calledWith(42, ''));
+          assert.isFalse(suggestAccountStub.called);
+          done();
+        });
+      });
+    });
+
+    suite('allowAnyUser set to true', () => {
+      setup(done => {
+        provider = new GrReviewerSuggestionsProvider(restAPI, change._number,
+            true);
+        provider.init().then(done);
+      });
+
+      test('getSuggestedAccounts is used', done => {
+        const suggestReviewerStub =
+            sandbox.stub(restAPI, 'getChangeSuggestedReviewers')
+                .returns(Promise.resolve([]));
+        const suggestAccountStub =
+            sandbox.stub(restAPI, 'getSuggestedAccounts')
+                .returns(Promise.resolve([]));
+
+        provider.getSuggestions('').then(() => {
+          assert.isFalse(suggestReviewerStub.called);
+          assert.isTrue(suggestAccountStub.calledOnce);
+          assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
+          done();
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/template_test_srcs/template_test.js b/polygerrit-ui/app/template_test_srcs/template_test.js
index 3de6227..ec3b7d5 100644
--- a/polygerrit-ui/app/template_test_srcs/template_test.js
+++ b/polygerrit-ui/app/template_test_srcs/template_test.js
@@ -35,6 +35,11 @@
   'GrReviewerUpdatesParser',
   'GrCountStringFormatter',
   'GrThemeApi',
+  'SiteBasedCache',
+  'FetchPromisesCache',
+  'GrRestApiHelper',
+  'GrDisplayNameUtils',
+  'GrReviewerSuggestionsProvider',
   'moment',
   'page',
   'util',
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 0fbc8f1..4754cd8 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -23,6 +23,7 @@
 <script src="/bower_components/web-component-tester/browser.js"></script>
 <script>
   const testFiles = [];
+  const scriptsPath = '../scripts/';
   const elementsPath = '../elements/';
   const behaviorsPath = '../behaviors/';
 
@@ -61,9 +62,9 @@
     'change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html',
     'change-list/gr-create-change-help/gr-create-change-help_test.html',
     'change-list/gr-dashboard-view/gr-dashboard-view_test.html',
+    // TODO: uncomment file & fix tests. The file was missed in this list for a long time.
+    // 'change-list/gr-repo-header/gr-repo-header_test.html',
     'change-list/gr-user-header/gr-user-header_test.html',
-    'change/gr-account-entry/gr-account-entry_test.html',
-    'change/gr-account-list/gr-account-list_test.html',
     'change/gr-change-actions/gr-change-actions_test.html',
     'change/gr-change-metadata/gr-change-metadata-it_test.html',
     'change/gr-change-metadata/gr-change-metadata_test.html',
@@ -105,10 +106,13 @@
     'core/gr-search-bar/gr-search-bar_test.html',
     'core/gr-smart-search/gr-smart-search_test.html',
     'diff/gr-comment-api/gr-comment-api_test.html',
+    'diff/gr-coverage-layer/gr-coverage-layer_test.html',
     'diff/gr-diff-builder/gr-diff-builder_test.html',
     'diff/gr-diff-cursor/gr-diff-cursor_test.html',
     'diff/gr-diff-highlight/gr-annotation_test.html',
     'diff/gr-diff-highlight/gr-diff-highlight_test.html',
+    // TODO: uncomment file & fix tests. The file was missed in this list for a long time.
+    // 'diff/gr-diff-host/gr-diff-host_test.html',
     'diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html',
     'diff/gr-diff-processor/gr-diff-processor_test.html',
     'diff/gr-diff-selection/gr-diff-selection_test.html',
@@ -127,6 +131,8 @@
     'plugins/gr-admin-api/gr-admin-api_test.html',
     'plugins/gr-styles-api/gr-styles-api_test.html',
     'plugins/gr-attribute-helper/gr-attribute-helper_test.html',
+    // TODO: uncomment file & fix tests. The file was missed in this list for a long time.
+    // 'plugins/gr-dom-hooks/gr-dom-hooks_test.html',
     'plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html',
     'plugins/gr-event-helper/gr-event-helper_test.html',
     'plugins/gr-external-style/gr-external-style_test.html',
@@ -135,7 +141,10 @@
     'plugins/gr-popup-interface/gr-popup-interface_test.html',
     'plugins/gr-repo-api/gr-repo-api_test.html',
     'plugins/gr-settings-api/gr-settings-api_test.html',
+    'plugins/gr-theme-api/gr-theme-api_test.html',
     'settings/gr-account-info/gr-account-info_test.html',
+    // TODO: uncomment file & fix tests. The file was missed in this list for a long time.
+    // 'settings/gr-agreements-list/gr-agreements-list_test.html',
     'settings/gr-change-table-editor/gr-change-table-editor_test.html',
     'settings/gr-cla-view/gr-cla-view_test.html',
     'settings/gr-edit-preferences/gr-edit-preferences_test.html',
@@ -149,7 +158,9 @@
     'settings/gr-settings-view/gr-settings-view_test.html',
     'settings/gr-ssh-editor/gr-ssh-editor_test.html',
     'settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html',
+    'shared/gr-account-entry/gr-account-entry_test.html',
     'shared/gr-account-label/gr-account-label_test.html',
+    'shared/gr-account-list/gr-account-list_test.html',
     'shared/gr-account-link/gr-account-link_test.html',
     'shared/gr-alert/gr-alert_test.html',
     'shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html',
@@ -161,33 +172,48 @@
     'shared/gr-comment-thread/gr-comment-thread_test.html',
     'shared/gr-comment/gr-comment_test.html',
     'shared/gr-copy-clipboard/gr-copy-clipboard_test.html',
+    'shared/gr-count-string-formatter/gr-count-string-formatter_test.html',
     'shared/gr-cursor-manager/gr-cursor-manager_test.html',
     'shared/gr-date-formatter/gr-date-formatter_test.html',
     'shared/gr-dialog/gr-dialog_test.html',
     'shared/gr-diff-preferences/gr-diff-preferences_test.html',
     'shared/gr-download-commands/gr-download-commands_test.html',
+    'shared/gr-dropdown/gr-dropdown_test.html',
     'shared/gr-dropdown-list/gr-dropdown-list_test.html',
     'shared/gr-editable-content/gr-editable-content_test.html',
     'shared/gr-editable-label/gr-editable-label_test.html',
     'shared/gr-formatted-text/gr-formatted-text_test.html',
+    'shared/gr-hovercard/gr-hovercard_test.html',
+    'shared/gr-js-api-interface/gr-annotation-actions-context_test.html',
+    'shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html',
     'shared/gr-js-api-interface/gr-change-actions-js-api_test.html',
     'shared/gr-js-api-interface/gr-change-reply-js-api_test.html',
     'shared/gr-js-api-interface/gr-js-api-interface_test.html',
+    // TODO: uncomment file & fix tests. The file was missed in this list for a long time.
+    // 'shared/gr-js-api-interface/gr-plugin-action-context_test.html',
     'shared/gr-js-api-interface/gr-plugin-endpoints_test.html',
     'shared/gr-js-api-interface/gr-plugin-rest-api_test.html',
     'shared/gr-fixed-panel/gr-fixed-panel_test.html',
     'shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html',
+    // TODO: uncomment file & fix tests. The file was missed in this list for a long time.
+    // 'shared/gr-label-info/gr-label-info_test.html',
     'shared/gr-lib-loader/gr-lib-loader_test.html',
     'shared/gr-limited-text/gr-limited-text_test.html',
     'shared/gr-linked-chip/gr-linked-chip_test.html',
     'shared/gr-linked-text/gr-linked-text_test.html',
     'shared/gr-list-view/gr-list-view_test.html',
+    'shared/gr-overlay/gr-overlay_test.html',
     'shared/gr-page-nav/gr-page-nav_test.html',
     'shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html',
     'shared/gr-rest-api-interface/gr-auth_test.html',
+    'shared/gr-rest-api-interface/gr-etag-decorator_test.html',
     'shared/gr-rest-api-interface/gr-rest-api-interface_test.html',
     'shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html',
+    'shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html',
+    // TODO: uncomment file & fix tests. The file was missed in this list for a long time.
+    // 'shared/gr-rest-api-interface/mock-diff-response_test.html',
     'shared/gr-select/gr-select_test.html',
+    'shared/gr-shell-command/gr-shell-command_test.html',
     'shared/gr-storage/gr-storage_test.html',
     'shared/gr-textarea/gr-textarea_test.html',
     'shared/gr-tooltip-content/gr-tooltip-content_test.html',
@@ -211,8 +237,10 @@
     'rest-client-behavior/rest-client-behavior_test.html',
     'gr-access-behavior/gr-access-behavior_test.html',
     'gr-admin-nav-behavior/gr-admin-nav-behavior_test.html',
-    'gr-anonymous-name-behavior/gr-anonymous-name-behavior_test.html',
     'gr-change-table-behavior/gr-change-table-behavior_test.html',
+    // TODO: uncomment file & fix tests. The file was missed in this list for a long time.
+    // 'gr-list-view-behavior/gr-list-view-behavior_test.html',
+    'gr-display-name-behavior/gr-display-name-behavior_test.html',
     'gr-patch-set-behavior/gr-patch-set-behavior_test.html',
     'gr-path-list-behavior/gr-path-list-behavior_test.html',
     'gr-tooltip-behavior/gr-tooltip-behavior_test.html',
@@ -226,5 +254,17 @@
     testFiles.push(file);
   }
 
+  const scripts = [
+    'gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html',
+    'gr-group-suggestions-provider/gr-group-suggestions-provider_test.html',
+    'gr-display-name-utils/gr-display-name-utils_test.html',
+    'gr-email-suggestions-provider/gr-email-suggestions-provider_test.html',
+  ];
+  /* eslint-enable max-len */
+  for (let file of scripts) {
+    file = scriptsPath + file;
+    testFiles.push(file);
+  }
+
   WCT.loadSuites(testFiles);
 </script>
diff --git a/proto/BUILD b/proto/BUILD
index cef28a1..57be265 100644
--- a/proto/BUILD
+++ b/proto/BUILD
@@ -1,3 +1,6 @@
+load("@rules_java//java:defs.bzl", "java_proto_library")
+load("@rules_proto//proto:defs.bzl", "proto_library")
+
 proto_library(
     name = "cache_proto",
     srcs = ["cache.proto"],
diff --git a/proto/testing/BUILD b/proto/testing/BUILD
index b9032cf..f32d745 100644
--- a/proto/testing/BUILD
+++ b/proto/testing/BUILD
@@ -1,3 +1,6 @@
+load("@rules_java//java:defs.bzl", "java_proto_library")
+load("@rules_proto//proto:defs.bzl", "proto_library")
+
 proto_library(
     name = "test_proto",
     testonly = 1,
diff --git a/resources/BUILD b/resources/BUILD
index 18d8df6..b53ae4c 100644
--- a/resources/BUILD
+++ b/resources/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_import")
 load("//tools/bzl:genrule2.bzl", "genrule2")
 
 java_import(
diff --git a/tools/BUILD b/tools/BUILD
index 3d0959b2..29626d9 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -3,6 +3,8 @@
     "JDK9_JVM_OPTS",
     "default_java_toolchain",
 )
+load("@rules_java//java:defs.bzl", "java_package_configuration")
+load("@rules_python//python:defs.bzl", "py_binary")
 
 exports_files(["nongoogle.bzl"])
 
diff --git a/tools/bzl/bazelisk_version.bzl b/tools/bzl/bazelisk_version.bzl
new file mode 100644
index 0000000..d8b3d10
--- /dev/null
+++ b/tools/bzl/bazelisk_version.bzl
@@ -0,0 +1,16 @@
+_template = """
+load("@bazel_skylib//lib:versions.bzl", "versions")
+
+def check_bazel_version():
+  versions.check(minimum_bazel_version = "{version}")
+""".strip()
+
+def _impl(repository_ctx):
+    repository_ctx.symlink(Label("@//:.bazelversion"), ".bazelversion")
+    bazelversion = repository_ctx.read(".bazelversion").strip()
+
+    repository_ctx.file("BUILD", executable = False)
+
+    repository_ctx.file("check.bzl", executable = False, content = _template.format(version = bazelversion))
+
+bazelisk_version = repository_rule(implementation = _impl)
diff --git a/tools/bzl/classpath.bzl b/tools/bzl/classpath.bzl
index 0d43be7..3be7a12 100644
--- a/tools/bzl/classpath.bzl
+++ b/tools/bzl/classpath.bzl
@@ -1,10 +1,10 @@
 def _classpath_collector(ctx):
     all = []
     for d in ctx.attr.deps:
-        if hasattr(d, "java"):
-            all.append(d.java.transitive_runtime_deps)
-            if hasattr(d.java.compilation_info, "runtime_classpath"):
-                all.append(d.java.compilation_info.runtime_classpath)
+        if JavaInfo in d:
+            all.append(d[JavaInfo].transitive_runtime_deps)
+            if hasattr(d[JavaInfo].compilation_info, "runtime_classpath"):
+                all.append(d[JavaInfo].compilation_info.runtime_classpath)
         elif hasattr(d, "files"):
             all.append(d.files)
 
diff --git a/tools/bzl/java.bzl b/tools/bzl/java.bzl
index 7c41fbe..8996b69 100644
--- a/tools/bzl/java.bzl
+++ b/tools/bzl/java.bzl
@@ -15,11 +15,13 @@
 # Syntactic sugar for native java_library() rule:
 #   accept exported_deps attributes
 
+load("@rules_java//java:defs.bzl", "java_library")
+
 def java_library2(deps = [], exported_deps = [], exports = [], **kwargs):
     if exported_deps:
         deps = deps + exported_deps
         exports = exports + exported_deps
-    native.java_library(
+    java_library(
         deps = deps,
         exports = exports,
         **kwargs
diff --git a/tools/bzl/javadoc.bzl b/tools/bzl/javadoc.bzl
index 754bd96..77c2d4a 100644
--- a/tools/bzl/javadoc.bzl
+++ b/tools/bzl/javadoc.bzl
@@ -17,8 +17,11 @@
 def _impl(ctx):
     zip_output = ctx.outputs.zip
 
-    transitive_jars = depset(transitive = [j.java.transitive_deps for j in ctx.attr.libs])
-    source_jars = depset(transitive = [j.java.source_jars for j in ctx.attr.libs])
+    transitive_jars = depset(transitive = [j[JavaInfo].transitive_deps for j in ctx.attr.libs])
+
+    # TODO(davido): Remove list to depset conversion on source_jars, when this issue is fixed:
+    # https://github.com/bazelbuild/bazel/issues/4221
+    source_jars = depset(transitive = [depset(j[JavaInfo].source_jars) for j in ctx.attr.libs])
 
     transitive_jar_paths = [j.path for j in transitive_jars.to_list()]
     dir = ctx.outputs.zip.path + ".dir"
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
index 1a30997..1cf82ea 100644
--- a/tools/bzl/junit.bzl
+++ b/tools/bzl/junit.bzl
@@ -18,6 +18,8 @@
 
 # See https://github.com/bazelbuild/bazel/issues/1017 for background.
 
+load("@rules_java//java:defs.bzl", "java_test")
+
 _OUTPUT = """import org.junit.runners.Suite;
 import org.junit.runner.RunWith;
 
@@ -55,7 +57,7 @@
         ctx.attr.outname,
     ))
 
-_GenSuite = rule(
+_gen_suite = rule(
     attrs = {
         "srcs": attr.label_list(allow_files = True),
         "outname": attr.string(),
@@ -73,7 +75,7 @@
 
 def junit_tests(name, srcs, **kwargs):
     s_name = name.replace("-", "_") + "TestSuite"
-    _GenSuite(
+    _gen_suite(
         name = s_name,
         srcs = srcs,
         outname = s_name,
@@ -84,7 +86,7 @@
         "//:java_next": POST_JDK8_OPTS,
         "//conditions:default": [],
     })
-    native.java_test(
+    java_test(
         name = name,
         test_class = s_name,
         srcs = srcs + [":" + s_name],
diff --git a/tools/bzl/maven.bzl b/tools/bzl/maven.bzl
index 71aa91c..36e3084e 100644
--- a/tools/bzl/maven.bzl
+++ b/tools/bzl/maven.bzl
@@ -14,6 +14,8 @@
 
 # Merge maven files
 
+load("@rules_java//java:defs.bzl", "java_import")
+
 def cmd(jars):
     return ("$(location //tools:merge_jars) $@ " +
             " ".join(["$(location %s)" % j for j in jars]))
@@ -25,7 +27,7 @@
         tools = srcs + ["//tools:merge_jars"],
         outs = ["%s__merged.jar" % name],
     )
-    native.java_import(
+    java_import(
         name = name,
         jars = [":%s__merged_bin" % name],
         **kwargs
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index 90dc607..ab753bd 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -102,8 +102,8 @@
     transitive_context_libs = []
     if ctx.attr.context:
         for jar in ctx.attr.context:
-            if hasattr(jar, "java"):
-                transitive_context_libs.append(jar.java.transitive_runtime_deps)
+            if JavaInfo in jar:
+                transitive_context_libs.append(jar[JavaInfo].transitive_runtime_deps)
             elif hasattr(jar, "files"):
                 transitive_context_libs.append(jar.files)
 
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index 066fe43..ed64d1b 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_binary", "java_library")
 load("//tools/bzl:genrule2.bzl", "genrule2")
 
 PLUGIN_DEPS = ["//plugins:plugin-lib"]
@@ -21,7 +22,7 @@
         dir_name = None,
         target_suffix = "",
         **kwargs):
-    native.java_library(
+    java_library(
         name = name + "__plugin",
         srcs = srcs,
         resources = resources,
@@ -35,7 +36,7 @@
     if not dir_name:
         dir_name = name
 
-    native.java_binary(
+    java_binary(
         name = "%s__non_stamped" % name,
         deploy_manifest_lines = manifest_entries + ["Gerrit-ApiType: plugin"],
         main_class = "Dummy",
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index 814a56f..e091fc1 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//tools/bzl:classpath.bzl", "classpath_collector")
 load("//tools/bzl:pkg_war.bzl", "LIBS", "PGMLIBS")
 load(